diff --git a/VERSION b/VERSION index 55732de..77fee73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.2-patch3 +1.9.3 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c49b88a..ea78d39 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.remotedisplay.player" minSdk = 24 targetSdk = 34 - versionCode = 41 - versionName = "1.9.2-patch3" + versionCode = 43 + versionName = "1.9.3" } signingConfigs { @@ -46,6 +46,13 @@ android { kotlinOptions { jvmTarget = "17" } + + testOptions { + // Let JVM unit tests exercise Android-dependent code paths (e.g. ContentCache's real + // download logic, which logs via android.util.Log) without Robolectric — stubbed Android + // APIs return defaults instead of throwing "not mocked". + unitTests.isReturnDefaultValues = true + } } dependencies { 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 819c09a..347fa73 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -46,6 +46,7 @@ class MainActivity : AppCompatActivity() { private lateinit var config: ServerConfig private lateinit var contentCache: ContentCache + private lateinit var downloadCoordinator: com.remotedisplay.player.data.DownloadCoordinator private lateinit var screenshotCapture: ScreenshotCapture private lateinit var touchInjector: TouchInjector @@ -138,6 +139,13 @@ class MainActivity : AppCompatActivity() { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) contentCache = ContentCache(this) + // Coordinated background downloads (single-flight + bounded pool + backoff) — reconnect-safe. + downloadCoordinator = com.remotedisplay.player.data.DownloadCoordinator( + cache = contentCache, + serverUrl = { config.serverUrl }, + socketAlive = { wsService?.isConnected() == true }, + onAck = { cid, status -> ackContentOnce(cid, status) } + ) screenshotCapture = ScreenshotCapture() touchInjector = TouchInjector() @@ -185,8 +193,17 @@ class MainActivity : AppCompatActivity() { // #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen) onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) }, onRequestRefresh = { wsService?.requestPlaylistRefresh() }, - onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) } + onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) }, + // Screen-resilience: the defined "waiting for content" state — ONLY on a fresh device + // with nothing to show yet (never while content is on screen; that path keeps current). + onWaitingForContent = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) } ) + // Screen-resilience: an item is playable only when its content is actually available — + // a widget, a remote stream, or a fully-downloaded local file. A not-yet/failed download is + // skipped (kept in the background) instead of blanking the screen on a loading state. + playlistController.setContentReadyCheck { item -> + item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId) + } // Setup media player mediaPlayer = MediaPlayerManager( @@ -493,31 +510,23 @@ class MainActivity : AppCompatActivity() { // Skip remote URL content - it streams directly if (!remoteUrl.isNullOrEmpty()) { - wsService?.sendContentAck(contentId, "ready") + ackContentOnce(contentId, "ready") continue } - if (!contentCache.isContentCached(contentId)) { - Log.i("MainActivity", "Downloading content: $filename") - var downloaded = false - for (attempt in 1..3) { - val file = contentCache.downloadContent(config.serverUrl, contentId, filename) - if (file != null) { - wsService?.sendContentAck(contentId, "ready") - downloaded = true - break - } - Log.w("MainActivity", "Download attempt $attempt failed for $filename") - if (attempt < 3) Thread.sleep(2000L * attempt) - } - if (!downloaded) wsService?.sendContentAck(contentId, "failed") - } + // Background download is now COORDINATED: single-flight per contentId + a bounded + // pool + failure backoff. So a watchdog/ConnectionGuard reconnect mid-fetch can't + // spawn a duplicate racing the .part, orphan/pile up threads, or storm a failing + // URL. ensure() is non-blocking and idempotent: it re-acks cached content (SEED-A), + // defers when the socket is down (watchdog owns recovery), respects backoff, and + // downloads at most once. It acks ready/failed itself (deduped via onAck). + downloadCoordinator.ensure(contentId, filename) } - // Start or resume playback after downloads complete — but ONLY in - // single-zone/fullscreen mode. In multi-zone, ZoneManager drives each - // zone; restarting the fullscreen controller here made it keep playing - // items behind the zones (wasted work + phantom audio for videos). + // Start/resume playback immediately — do NOT wait on downloads (they're async now). + // Screen-resilience plays whatever is cached and skips not-yet-ready items; the 3s + // recheck swaps new content in once its download completes. Single-zone only; in + // multi-zone, ZoneManager drives each zone. handler.post { if (zoneManager?.hasZones() != true) playlistController.startIfNeeded() } @@ -529,6 +538,7 @@ class MainActivity : AppCompatActivity() { } wsService?.onContentDelete = { contentId -> + downloadCoordinator.forget(contentId) // drop in-flight/backoff state so a re-add re-downloads contentCache.deleteContent(contentId) playlistController.removeContent(contentId) // Update cached playlist to reflect deletion @@ -655,6 +665,9 @@ class MainActivity : AppCompatActivity() { wsService?.onRegistered = { _ -> hideStatus() + // Root-2 (SEED-B): a disconnect may have dropped in-flight content-acks. Clear the + // de-dup set so the next playlist-update re-acks all content and the CMS re-syncs. + ackedContent.clear() } wsService?.onUnpaired = { @@ -669,6 +682,20 @@ class MainActivity : AppCompatActivity() { } } + // Root-2 content-ack de-dup. Re-acking content state (SEED-A) fixes the CMS "stuck downloading" + // label, but we must not re-ack the same (content,status) every 60s playlist refresh. This set + // is cleared on each (re)registration (see onRegistered) so a reconnect re-acks everything the + // server may have missed while we were disconnected (SEED-B), but is quiet within a session. + private val ackedContent = java.util.Collections.synchronizedSet(HashSet()) + + private fun ackContentOnce(contentId: String, status: String) { + if (ackedContent.add("$contentId:$status")) { + // a status change for this content supersedes the opposite one + ackedContent.remove("$contentId:${if (status == "ready") "failed" else "ready"}") + wsService?.sendContentAck(contentId, status) + } + } + private fun playItem(item: PlaylistItem) { hideStatus() com.remotedisplay.player.util.DebugLog.i("Player", "playItem: ${item.filename} mime=${item.mimeType} widget=${item.widgetId ?: "-"} zone=fullscreen") @@ -704,22 +731,17 @@ class MainActivity : AppCompatActivity() { return } - // Local content - download if not cached + // Local content - play from cache. Screen-resilience: the controller only advances to + // items whose content is READY, so reaching here uncached is a rare race (e.g. the file + // was evicted between selection and play). NEVER blank or show a "Downloading…" screen — + // keep whatever is on screen and move on; the background download loop (onPlaylistUpdate) + // fetches it and it plays once fully + validly downloaded. Content update is a BACKGROUND + // operation; we only ever SWAP to fully-downloaded content. val file = contentCache.getCachedFile(item.contentId) if (file == null) { - Log.w("MainActivity", "Content not cached: ${item.contentId}, downloading...") - showStatus("Downloading ${item.filename}...") - thread { - val downloaded = contentCache.downloadContent(config.serverUrl, item.contentId, item.filename) - handler.post { - if (downloaded != null) { - playFile(item, downloaded) - } else { - showStatus("Download failed: ${item.filename}") - handler.postDelayed({ playlistController.next() }, 3000) - } - } - } + Log.i("MainActivity", "Content not ready at play time (${item.filename}) — keeping screen, advancing (bg download continues)") + downloadCoordinator.ensure(item.contentId, item.filename) // ensure it's being fetched (single-flight) + handler.post { playlistController.next() } return } @@ -1025,6 +1047,7 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { remoteStreaming = false + if (::downloadCoordinator.isInitialized) downloadCoordinator.shutdown() // cancel in-flight downloads (no orphan/leak) zoneManager?.cleanup() if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView if (::mediaPlayer.isInitialized) { diff --git a/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt b/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt index 70e0f91..a8ccb9c 100644 --- a/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt +++ b/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt @@ -18,6 +18,23 @@ class RemoteDisplayApp : Application() { override fun onCreate() { super.onCreate() createNotificationChannel() + installCrashExitSignal() + } + + // Exit-signal contract v1 — 'crashed'. A global uncaught-exception handler fires a BEST-EFFORT + // blocking last-gasp to the server, then delegates to the previous default handler so the crash + // still propagates and the process dies normally. Runs on the crashing thread (already dying), so + // the short blocking POST is acceptable. BEST-EFFORT: a native/OOM kill runs no JVM handler -> + // nothing is sent -> the server infers 'silent'. Honesty: only ever emits 'crashed' here. + private fun installCrashExitSignal() { + val prev = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + val detail = (throwable.javaClass.simpleName + ": " + (throwable.message ?: "")).trim() + com.remotedisplay.player.service.ExitSignal.send(this, "crashed", detail) + } catch (t: Throwable) { /* never mask the original crash */ } + prev?.uncaughtException(thread, throwable) // chain -> normal crash reporting + process death + } } private fun createNotificationChannel() { diff --git a/android/app/src/main/java/com/remotedisplay/player/data/CacheValidation.kt b/android/app/src/main/java/com/remotedisplay/player/data/CacheValidation.kt new file mode 100644 index 0000000..bf07dd6 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/data/CacheValidation.kt @@ -0,0 +1,20 @@ +package com.remotedisplay.player.data + +/** + * Root-2 caching fix — the pure, unit-testable "is this download complete?" rule (no Android deps, + * same pattern as ConnectionGuard / OtaThrottle). [ContentCache] is the imperative shell (OkHttp + + * filesystem); this owns the integrity decision so a truncated/partial body is never promoted to + * the cache dir and later served as if it were a whole file (which previously wedged playback on a + * corrupt asset with no error path). + */ +object CacheValidation { + /** + * Complete iff we wrote at least one byte AND — when the server declared a Content-Length + * ([expectedBytes] > 0) — we wrote exactly that many. A truncated body + * (bytesWritten < expectedBytes) is INCOMPLETE and must be discarded + re-fetched. When the + * length is unknown (chunked / -1), fall back to ">0 bytes" (an interrupted copy throws and is + * discarded upstream, so a silent zero-length truncation is the only residual gap). + */ + fun isComplete(bytesWritten: Long, expectedBytes: Long): Boolean = + bytesWritten > 0 && (expectedBytes <= 0 || bytesWritten == expectedBytes) +} diff --git a/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt b/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt index 0a37f6a..18d123c 100644 --- a/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt +++ b/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt @@ -8,16 +8,31 @@ import java.io.File import java.io.FileOutputStream import java.util.concurrent.TimeUnit -class ContentCache(private val context: Context) { - - private val cacheDir = File(context.filesDir, "content_cache").also { it.mkdirs() } - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.MINUTES) - .build() +/** + * Root-2 caching fixes vs the "stuck downloading / frozen" bug: + * - a hard OVERALL [callTimeout] so a slow-drip/stalled download on a HEALTHY socket can't hang + * forever (the old client only had a per-read timeout, which a trickle never trips), + * - download to a `.part` temp + integrity-check (Content-Length) via [CacheValidation] + atomic + * rename, so a truncated/interrupted body is NEVER promoted to the cache and played as if whole, + * - exact-prefix cache lookup that also excludes in-flight `.part` files. + * + * The primary constructor takes the cache dir + client directly so the real download logic is + * unit-testable (see ContentDownloadTest) against a local server without an Android Context; the + * [Context] convenience constructor is what the app uses. + */ +class ContentCache internal constructor( + private val cacheDir: File, + private val client: OkHttpClient +) { + constructor(context: Context) : this( + File(context.filesDir, "content_cache").also { it.mkdirs() }, + defaultClient() + ) fun getCachedFile(contentId: String): File? { - val files = cacheDir.listFiles { _, name -> name.startsWith(contentId) } + // Match "." 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 } } @@ -26,35 +41,61 @@ class ContentCache(private val context: Context) { } fun downloadContent(serverUrl: String, contentId: String, filename: String): File? { + val ext = filename.substringAfterLast('.', "mp4") + val finalFile = File(cacheDir, "${contentId}.${ext}") + val partFile = File(cacheDir, "${contentId}.${ext}${PART_SUFFIX}") try { val url = "${serverUrl}/api/content/${contentId}/file" val request = Request.Builder().url(url).build() - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - Log.e("ContentCache", "Download failed: ${response.code}") - return null - } - - val ext = filename.substringAfterLast('.', "mp4") - val file = File(cacheDir, "${contentId}.${ext}") - - response.body?.byteStream()?.use { input -> - FileOutputStream(file).use { output -> - input.copyTo(output) + // .use closes the Response (and its body) on every path — also fixes the prior + // error-path body/connection leak. + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.e("ContentCache", "Download failed: ${response.code}") + return null } + // We issue a plain (no-Range) GET, so a 206 Partial Content means a proxy/CDN + // returned a PARTIAL body whose Content-Length matches that partial — which would + // pass the byte-count integrity check and promote a truncated file. Require a full 200. + if (response.code == 206) { + Log.e("ContentCache", "Refusing 206 Partial Content for $filename — not a complete file") + return null + } + partFile.delete() // clear any earlier partial before writing + val body = response.body ?: return null + val expected = body.contentLength() // -1 when unknown (chunked) + var written = 0L + body.byteStream().use { input -> + FileOutputStream(partFile).use { output -> written = input.copyTo(output) } + } + // Root-2: a truncated body must NOT be promoted to the cache and played as whole. + if (!CacheValidation.isComplete(written, expected)) { + Log.e("ContentCache", "Incomplete download ($written/$expected bytes) for $filename — discarding partial") + partFile.delete() + return null + } + finalFile.delete() + if (!partFile.renameTo(finalFile)) { + Log.e("ContentCache", "Rename failed for $filename") + partFile.delete() + return null + } + Log.i("ContentCache", "Downloaded: $filename -> ${finalFile.absolutePath} ($written bytes)") + return finalFile } - - Log.i("ContentCache", "Downloaded: $filename -> ${file.absolutePath}") - return file } catch (e: Exception) { + // Includes callTimeout / readTimeout (a stalled download on a healthy socket) and any + // mid-stream break — never leave a partial at the real path. Log.e("ContentCache", "Download error: ${e.message}") + partFile.delete() return null } } fun deleteContent(contentId: String) { - cacheDir.listFiles { _, name -> name.startsWith(contentId) }?.forEach { it.delete() } + // Exact-prefix (with the dot) so we don't delete a different id's file — and this also + // sweeps the "..part" temp. + cacheDir.listFiles { _, name -> name.startsWith("$contentId.") }?.forEach { it.delete() } Log.i("ContentCache", "Deleted cached content: $contentId") } @@ -65,4 +106,15 @@ class ContentCache(private val context: Context) { fun getCacheSize(): Long { return cacheDir.listFiles()?.sumOf { it.length() } ?: 0L } + + companion object { + private const val PART_SUFFIX = ".part" + + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) // Root-2: a stalled stream (no bytes 30s) aborts (was 5min) + .writeTimeout(30, TimeUnit.SECONDS) + .callTimeout(5, TimeUnit.MINUTES) // Root-2: hard OVERALL cap so a slow-drip can't hang forever + .build() + } } diff --git a/android/app/src/main/java/com/remotedisplay/player/data/DownloadCoordinator.kt b/android/app/src/main/java/com/remotedisplay/player/data/DownloadCoordinator.kt new file mode 100644 index 0000000..8302bdf --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/data/DownloadCoordinator.kt @@ -0,0 +1,111 @@ +package com.remotedisplay.player.data + +import android.os.SystemClock +import android.util.Log +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * Resolves the background-download × reconnect A-bucket bug. Previously every playlist sweep spawned + * a detached, un-deduped download `thread{}` writing a deterministic `${id}.ext.part` path. On a + * watchdog / ConnectionGuard reconnect the re-register drove a NEW sweep that started a DUPLICATE + * download of the same content mid-fetch — two writers racing the shared `.part` (a corrupt file + * whose byte-count still matched Content-Length, so it passed the integrity check and was promoted), + * plus orphaned threads piling up across reconnects, resurrecting "stuck downloading" through the + * reconnect path — the exact failure the caching fix was meant to eliminate. + * + * This coordinator makes background downloads reconnect-resilient: + * - SINGLE-FLIGHT per contentId: a reconnect's re-sweep never starts a second download for content + * already in flight. The in-flight fetch runs on its OWN OkHttp connection (independent of the + * socket), so a reconnect underneath it does not interrupt it — it CONTINUES and completes; if + * the network drop truncated it, ContentCache's partial detection discards the `.part` and it + * cleanly RESTARTS on a later sweep. Never orphaned, never a duplicate racing the `.part`. + * - BOUNDED executor: caps concurrent downloads (no thread pileup across reconnects); shutdown() + * cancels in-flight work so nothing orphans or pins the Activity. + * - BACKOFF with failure memory: a permanently-failing URL backs off exponentially instead of + * re-fetching every 60s forever (no retry storm). The screen keeps serving cached content + * (screen-resilience is untouched) and the CMS gets one "failed" ack, not a stuck "downloading". + * + * Atomic-swap + partial detection are ContentCache's job and are unchanged; this only guarantees a + * SINGLE writer per `.part` so that guarantee can't be defeated by a concurrent duplicate. + */ +class DownloadCoordinator( + private val cache: ContentCache, + private val serverUrl: () -> String, + private val socketAlive: () -> Boolean, + private val onAck: (contentId: String, status: String) -> Unit, + private val executor: ExecutorService = Executors.newFixedThreadPool(MAX_CONCURRENT), + private val now: () -> Long = { SystemClock.elapsedRealtime() } +) { + private val inFlight = Collections.synchronizedSet(HashSet()) + private val attempts = ConcurrentHashMap() + private val nextAttemptAt = ConcurrentHashMap() + + /** + * Ensure [contentId] is (being) downloaded. Called for each local assignment on EVERY playlist + * sweep — the 60s refresh and every post-reconnect re-register included. Idempotent and + * non-blocking: it enqueues at most ONE download per contentId and returns immediately. + */ + 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) + // 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 + try { + executor.execute { runDownload(contentId, filename) } + } catch (e: Throwable) { + inFlight.remove(contentId) // executor rejected (shut down) — don't leak the guard + } + } + + private fun runDownload(contentId: String, filename: String) { + try { + val file = cache.downloadContent(serverUrl(), contentId, filename) + if (file != null) { + attempts.remove(contentId); nextAttemptAt.remove(contentId) + // Ack only reaches a live socket; if it dropped, the reconnect's re-register clears + // the ack set and the next sweep re-acks the now-cached file. + if (socketAlive()) onAck(contentId, "ready") + } else { + onFailure(contentId) // includes a reconnect-truncated .part (ContentCache returned null) + } + } catch (e: Throwable) { + Log.w("DownloadCoordinator", "download $contentId failed: ${e.message}") + onFailure(contentId) + } finally { + inFlight.remove(contentId) + } + } + + private fun onFailure(contentId: String) { + val n = (attempts[contentId] ?: 0) + 1 + attempts[contentId] = n + val delay = minOf(BACKOFF_BASE_MS shl (n - 1).coerceIn(0, 20), BACKOFF_MAX_MS) + nextAttemptAt[contentId] = now() + delay + if (socketAlive()) onAck(contentId, "failed") + } + + /** Content deleted server-side — drop download/backoff state so a re-add downloads fresh. */ + fun forget(contentId: String) { + inFlight.remove(contentId); attempts.remove(contentId); nextAttemptAt.remove(contentId) + } + + /** Teardown (onDestroy): cancel in-flight downloads so none orphan or pin the Activity. */ + fun shutdown() { + try { executor.shutdownNow() } catch (_: Throwable) {} + inFlight.clear() + } + + // test visibility + internal fun isInFlight(contentId: String) = inFlight.contains(contentId) + + companion object { + const val MAX_CONCURRENT = 3 + const val BACKOFF_BASE_MS = 15_000L + const val BACKOFF_MAX_MS = 5 * 60_000L + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt index ee47535..d954ec6 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt @@ -46,6 +46,14 @@ class MediaPlayerManager( onVideoComplete() } } + // Root-2: a corrupt/undecodable video used to freeze the playlist forever — only + // STATE_ENDED advanced, and an error goes to STATE_IDLE, so onVideoComplete never + // fired. Treat a playback error like a completion so the loop moves on instead of + // wedging on the broken item (mirrors the web/.wgt onerror -> advance). + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + Log.e("MediaPlayerManager", "Playback error (${error.errorCodeName}) — advancing: ${error.message}") + onVideoComplete() + } }) } } diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt index b9d8a6c..ee4de70 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt @@ -31,8 +31,13 @@ class PlaylistController( private val onItemChanged: (PlaylistItem?) -> Unit, private val onPlaylistEmpty: () -> Unit, private val onRequestRefresh: (() -> Unit)? = null, - private val onNothingScheduled: (() -> Unit)? = null + private val onNothingScheduled: (() -> Unit)? = null, + // Screen-resilience: the defined "content isn't downloaded yet" waiting state, shown ONLY when + // nothing has ever played (fresh device). Never used while content is on screen. + private val onWaitingForContent: (() -> Unit)? = null ) { + private companion object { const val CONTENT_RECHECK_MS = 3000L } + private val items = mutableListOf() private var currentIndex = -1 private val handler = Handler(Looper.getMainLooper()) @@ -42,6 +47,14 @@ class PlaylistController( @Volatile private var effectiveTimezone: String? = null private var retryRunnable: Runnable? = null + // Screen-resilience: an item is playable only when its content is actually AVAILABLE + // (widget / remote-stream / fully-downloaded local file). Injected by MainActivity (which owns + // the cache); the default keeps every item playable so behavior is unchanged until it's set. + private var contentReady: (PlaylistItem) -> Boolean = { true } + fun setContentReadyCheck(f: (PlaylistItem) -> Boolean) { contentReady = f } + // True while a valid item is rendered on screen — so we NEVER blank it for a pending download. + private var hasContentOnScreen = false + // Video wall: followers don't self-advance — the leader's wall:sync drives the index. private var wallFollower = false // Wall-clock at which the current item started playing, for non-video sync position. @@ -149,9 +162,13 @@ class PlaylistController( } } // Current item was removed or nothing was playing - start from the first - // schedule-active item; idle if none are active right now. - val idx = firstActiveIndex() - if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled() + // schedule-active AND downloaded item. Distinguish the two idle reasons: daypart + // closed (nothing scheduled) => defined idle; scheduled-but-not-yet-downloaded => + // keep current content / waiting, never blank. + val fp = PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) } + if (fp >= 0) { currentIndex = fp; playCurrentItem() } + else if (firstActiveIndex() < 0) showNothingScheduled() + else onContentNotReady() } else { currentIndex = 0 } @@ -174,11 +191,12 @@ class PlaylistController( fun start() { isRunning = true if (items.isEmpty()) { onPlaylistEmpty(); return } - // #74/#75: begin on the first schedule-active item; idle if none. - val idx = firstActiveIndex() - if (idx < 0) { showNothingScheduled(); return } - currentIndex = idx - playCurrentItem() + // #74/#75: begin on the first schedule-active item; daypart-closed => defined idle. + if (firstActiveIndex() < 0) { showNothingScheduled(); return } + // Screen-resilience: only start on an item whose content is downloaded; if the scheduled + // content isn't ready yet, keep current/wait (never blank on a loading state). + val idx = PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) } + if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady() } fun startIfNeeded() { @@ -200,17 +218,21 @@ class PlaylistController( isRunning = false cancelAdvance() cancelRetry() + hasContentOnScreen = false } fun next() { if (items.isEmpty()) return // Request a playlist refresh between plays so new content gets picked up onRequestRefresh?.invoke() - // #74/#75: advance to the next item the schedule allows now; idle if none. - val idx = nextActiveIndex(currentIndex) - if (idx < 0) { showNothingScheduled(); return } - currentIndex = idx - playCurrentItem() + // #74/#75: daypart closed (nothing scheduled now) => defined idle. + if (firstActiveIndex() < 0) { showNothingScheduled(); return } + // Screen-resilience: advance to the next schedule-active AND downloaded item. Unready items + // are SKIPPED (their background download continues), so a stalled/failed download of the + // next content never blanks the screen — we keep looping the content we already have. If + // NOTHING is downloaded yet, keep current content / show the waiting state, never blank. + val idx = PlaylistSelection.nextPlayableIndex(items.size, currentIndex) { playableNow(it) } + if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady() } fun onVideoComplete() { @@ -227,6 +249,7 @@ class PlaylistController( itemStartedAt = System.currentTimeMillis() Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)") onItemChanged(item) + hasContentOnScreen = true // a valid item is now rendered — protect it from being blanked // For images and widgets, auto-advance after duration. For videos, wait // for the completion callback. Wall followers never auto-advance — the @@ -257,6 +280,40 @@ class PlaylistController( item.schedules.isEmpty() || ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone) + // Playable NOW = schedule-active AND its content is downloaded/available. + private fun playableNow(i: Int): Boolean = + i in items.indices && scheduleAllows(items[i]) && contentReady(items[i]) + + // Screen-resilience: the scheduled item(s) exist but their content isn't downloaded yet. + // NEVER blank a screen that is already showing content — keep it and re-check soon (the + // background download finishes, or the watchdog restores connectivity). Only show the defined + // waiting/setup state on a fresh device that has never played anything. + private fun onContentNotReady() { + cancelAdvance() + when (PlaylistSelection.whenNonePlayable(hasContentOnScreen)) { + PlaylistSelection.NonePlayable.KEEP_CURRENT -> { /* leave current content on screen */ } + PlaylistSelection.NonePlayable.SHOW_WAITING -> { + hasContentOnScreen = false + (onWaitingForContent ?: onNothingScheduled ?: onPlaylistEmpty)() + } + } + scheduleContentRecheck() + } + + // Re-evaluate playability shortly (a pending download may have finished / a daypart opened). + // Faster than the schedule re-check because a finished download should play promptly. + private fun scheduleContentRecheck() { + cancelRetry() + retryRunnable = Runnable { + if (isRunning && items.isNotEmpty()) { + if (firstActiveIndex() < 0) { showNothingScheduled(); return@Runnable } + val idx = PlaylistSelection.nextPlayableIndex(items.size, currentIndex) { playableNow(it) } + if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady() + } + } + handler.postDelayed(retryRunnable!!, CONTENT_RECHECK_MS) + } + private fun firstActiveIndex(): Int { for (i in items.indices) if (scheduleAllows(items[i])) return i return -1 @@ -275,6 +332,7 @@ class PlaylistController( // daypart may open. (Boundary re-evaluation otherwise happens on advance.) private fun showNothingScheduled() { cancelAdvance() + hasContentOnScreen = false // the daypart genuinely closed — a defined idle, not a blank-bug (onNothingScheduled ?: onPlaylistEmpty)() cancelRetry() retryRunnable = Runnable { diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistSelection.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistSelection.kt new file mode 100644 index 0000000..eefa073 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistSelection.kt @@ -0,0 +1,46 @@ +package com.remotedisplay.player.player + +/** + * Pure, unit-testable playlist SELECTION + screen-resilience decisions (no Android deps, same + * pattern as ConnectionGuard / OtaThrottle). PlaylistController is the imperative shell (Handler / + * playback); this owns "which item can play right now" and "what to do when none can", so the + * viewer-visible invariant has real coverage: + * + * a pending/failed/stalled content download must NEVER blank or freeze a screen that is showing + * content — the player keeps showing what it already has and only swaps to new content once it's + * fully + validly downloaded. + */ +object PlaylistSelection { + /** First index for which [isPlayable] holds, or -1 if none. */ + fun firstPlayableIndex(size: Int, isPlayable: (Int) -> Boolean): Int { + for (i in 0 until size) if (isPlayable(i)) return i + return -1 + } + + /** + * Next index after [from] (wrapping) for which [isPlayable] holds, or -1 if none. With a single + * playable item it returns that item (loop), so a device keeps looping the content it HAS while + * other items are still downloading. + */ + fun nextPlayableIndex(size: Int, from: Int, isPlayable: (Int) -> Boolean): Int { + if (size <= 0) return -1 + for (i in 1..size) { + val idx = (((from + i) % size) + size) % size + if (isPlayable(idx)) return idx + } + return -1 + } + + enum class NonePlayable { KEEP_CURRENT, SHOW_WAITING } + + /** + * When nothing is playable (e.g. the scheduled item's content isn't downloaded yet): NEVER + * blank a screen that is showing content. Keep the current content if we have some on screen; + * only fall to the defined waiting/setup state when there is genuinely nothing displayed yet + * (a fresh device that has never successfully played anything). This is the one decision that + * separates "nothing to show yet" (acceptable) from "had content but blanked while updating" + * (the bug this fix forbids). + */ + fun whenNonePlayable(hasContentOnScreen: Boolean): NonePlayable = + if (hasContentOnScreen) NonePlayable.KEEP_CURRENT else NonePlayable.SHOW_WAITING +} diff --git a/android/app/src/main/java/com/remotedisplay/player/service/ExitSignal.kt b/android/app/src/main/java/com/remotedisplay/player/service/ExitSignal.kt new file mode 100644 index 0000000..b22a879 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/service/ExitSignal.kt @@ -0,0 +1,61 @@ +package com.remotedisplay.player.service + +import android.content.Context +import com.remotedisplay.player.data.ServerConfig +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * Exit-signal contract v1 — best-effort "last gasp" (manner of death), APK conformance. + * + * Sent via a BLOCKING OkHttp POST to /api/device/exit, NOT socket.emit: the socket emit path is + * async with no flush, so it will not reliably leave the buffer before the process dies. A short, + * bounded blocking POST from the crashing thread (about to die anyway) / a worker thread is the + * reliable transport on Android (matches the beacon the browser/Tizen clients use). + * + * Categories (honesty by construction — only ever these two; anything else -> server infers 'silent'): + * - "crashed" : the global uncaught-exception handler fired (RemoteDisplayApp). + * - "clean_exit" : Service.onDestroy on COOPERATIVE teardown (stopService/unbind/memory-reclaim-with- + * grace). NOT onStop/onPause (those fire on backgrounding). force-stop / MDM-uninstall + * / SIGKILL / OOM skip all callbacks -> nothing is sent -> server infers 'silent'. + * Idempotent: the first confident signal wins (a crash is never relabelled clean_exit). + */ +object ExitSignal { + @Volatile private var sent = false + private val JSON = "application/json".toMediaType() + private val client = OkHttpClient.Builder() + .callTimeout(2, TimeUnit.SECONDS) + .connectTimeout(2, TimeUnit.SECONDS) + .writeTimeout(2, TimeUnit.SECONDS) + .build() + + fun send(context: Context, reason: String, detail: String?) { + try { + if (sent) return + if (reason != "crashed" && reason != "clean_exit") return + val cfg = ServerConfig(context.applicationContext) + val id = cfg.deviceId + val token = cfg.deviceToken + val url = cfg.serverUrl + if (id.isEmpty() || token.isEmpty() || url.isEmpty()) return // unpaired -> nothing to attribute + sent = true + val payload = JSONObject().apply { + put("device_id", id) + put("device_token", token) + put("reason", reason) + if (!detail.isNullOrBlank()) put("detail", detail.take(200)) + }.toString() + val req = Request.Builder() + .url(url.trimEnd('/') + "/api/device/exit") + .post(payload.toRequestBody(JSON)) + .build() + client.newCall(req).execute().use { /* fire-and-forget; response ignored */ } + } catch (t: Throwable) { + /* a dying process must never throw further out of the last gasp */ + } + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/service/LivenessWatchdog.kt b/android/app/src/main/java/com/remotedisplay/player/service/LivenessWatchdog.kt new file mode 100644 index 0000000..01a47ea --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/service/LivenessWatchdog.kt @@ -0,0 +1,60 @@ +package com.remotedisplay.player.service + +/** + * v4 canonical liveness contract — the pure, unit-testable decision logic for the client-side + * half-open watchdog (no Android / Socket.IO deps, same pattern as [ConnectionGuard] / + * [OtaThrottle]). WebSocketService is the imperative shell: it tracks lastServerMessageAt (ANY + * inbound refreshes it), arms only after a device:heartbeat-ack (degrade-safe), and on each + * heartbeat tick asks THIS object whether a connected-but-silent socket is half-open and, if so, + * whether the anti-thundering-herd backoff allows a reconnect now. + * + * ANTI-THUNDERING-HERD (must not become the flood #143/#149 fixed): the THRESHOLD is jittered + * (45s ± up to 10s) so a fleet doesn't all declare half-open at once under a shared cause (server + * load delaying acks), and the reconnect BACKOFF is exponential-with-jitter (1,2,4,8,16… capped, + * ±20%) so repeated failures spread out and back off instead of hammering. Load-adaptation is by + * the client's OWN ack-silence — there is deliberately NO status/health poll (that is itself a + * second herd). + * + * Canonical defaults match the .wgt and /player so all three exhibit identical on-the-wire + * behavior; a platform-driven deviation would be documented here. + */ +object LivenessWatchdog { + const val THRESHOLD_BASE_MS = 45_000L // v4 canonical: 45s … + const val THRESHOLD_JITTER_MS = 10_000L // … ± up to 10s + const val BACKOFF_BASE_MS = 1_000L // v4 canonical: 1s, 2s, 4s, 8s, 16s … + const val BACKOFF_CAP_MS = 30_000L // … capped (within the contract's ~30–60s band) + private const val BACKOFF_JITTER_FRACTION = 0.2 // … each ± ~20% + + /** + * Watchdog threshold with jitter: 45s ± up to 10s. [rand] is a uniform value in [0, 1) + * (injected so the pure logic is testable); the result is in [35s, 55s). + */ + fun thresholdMs(rand: Double): Long = + THRESHOLD_BASE_MS + ((rand - 0.5) * 2 * THRESHOLD_JITTER_MS).toLong() + + /** + * Exponential reconnect backoff with ±20% jitter. [attempt] is 1-based (the 1st reconnect + * uses the 1s step). Steps double (1s,2s,4s,8s,16s,…) and saturate at [BACKOFF_CAP_MS]; the + * shift is clamped so a large attempt count can't overflow. + */ + fun backoffMs(attempt: Int, rand: Double): Long { + val steps = (attempt - 1).coerceIn(0, 20) + val base = minOf(BACKOFF_BASE_MS shl steps, BACKOFF_CAP_MS) + val jitter = ((rand - 0.5) * 2 * BACKOFF_JITTER_FRACTION * base).toLong() + return base + jitter + } + + /** + * HALF-OPEN decision: reconnect only when a socket that is [connected] (Socket.IO still + * reports it up — the state its own auto-reconnect can't see) and whose liveness we have + * [armed] (seen ≥1 heartbeat-ack — degrade-safe: an ack-less/old server never arms us) has + * been silent longer than [thresholdMs]. Pure; mirrors the .wgt's watchdogShouldReconnect. + */ + fun isHalfOpen(armed: Boolean, connected: Boolean, silenceMs: Long, thresholdMs: Long): Boolean = + armed && connected && silenceMs > thresholdMs + + /** Backoff gate: a new reconnect attempt is allowed only once the backoff for the current + * attempt count has elapsed since the last one — spaces repeated failures fleet-wide. */ + fun mayReconnectNow(msSinceLastAttempt: Long, backoffMs: Long): Boolean = + msSinceLastAttempt >= backoffMs +} diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index cb20727..ce8dbe3 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -8,7 +8,9 @@ import android.os.Binder import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.SystemClock import android.util.Log +import kotlin.random.Random import androidx.core.app.NotificationCompat import com.remotedisplay.player.MainActivity import com.remotedisplay.player.RemoteDisplayApp @@ -35,6 +37,20 @@ class WebSocketService : Service() { private var heartbeatRunnable: Runnable? = null private val binder = LocalBinder() + // v4 liveness watchdog state (see LivenessWatchdog for the pure decision logic). + // lastServerMessageAt: ANY inbound server message refreshes it (markAlive, wired into safeOn) — + // uses the monotonic elapsedRealtime clock so an NTP/wall-clock jump can't false-fire or blind + // the watchdog. livenessConfirmed: ARMED only after a device:heartbeat-ack (degrade-safe — an + // ack-less/old server never arms us, so no false-fire storm). currentThresholdMs: per-connection + // jittered 45s ± up to 10s. watchdogAttempt/lastWatchdogAttemptAt: exponential-backoff gate. + @Volatile private var lastServerMessageAt = 0L + @Volatile private var livenessConfirmed = false + @Volatile private var currentThresholdMs = LivenessWatchdog.THRESHOLD_BASE_MS + private var watchdogAttempt = 0 + private var lastWatchdogAttemptAt = 0L + + private fun markAlive() { lastServerMessageAt = SystemClock.elapsedRealtime() } + companion object { // #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. @@ -101,6 +117,10 @@ class WebSocketService : Service() { // exception on the Socket.IO IO thread and crash the whole app. private fun Socket.safeOn(event: String, handler: (Array) -> Unit): Socket { on(event) { args -> + // v4: ANY inbound server message refreshes liveness (not just acks). The half-open + // decision additionally requires socket.connected(), so refreshing on a disconnect + // event is harmless. This is the single central receive-path hook. + markAlive() try { @Suppress("UNCHECKED_CAST") handler(args as Array) @@ -138,6 +158,14 @@ class WebSocketService : Service() { currentUrl = url socketActive = true + // v4 watchdog: a fresh socket is assumed alive; DIS-arm until it earns an ack again + // (degrade-safe), and pick a new jittered threshold for this connection so a fleet doesn't + // declare half-open in lockstep. watchdogAttempt is intentionally NOT reset here — it + // tracks repeated reconnect failures across sockets and resets on a healthy ack. + lastServerMessageAt = SystemClock.elapsedRealtime() + livenessConfirmed = false + currentThresholdMs = LivenessWatchdog.thresholdMs(Random.nextDouble()) + try { val options = IO.Options().apply { forceNew = true @@ -196,6 +224,17 @@ class WebSocketService : Service() { startHeartbeat() } + // v4 degrade-safe ARM: the watchdog arms ONLY after the first heartbeat-ack, so a + // server that never acks (old/pre-contract) never arms us -> no false-fire storm. + // markAlive already fired via safeOn (any inbound); a healthy ack also resets the + // reconnect backoff. Known ack-gap (reconnecting-not-yet-re-registered) is benign: + // arm-after-ack + any-inbound-refresh keep the watchdog from firing in that window. + safeOn("device:heartbeat-ack") { + if (!livenessConfirmed) Log.i("WebSocketService", "v4 watchdog: ARMED (first heartbeat-ack)") + livenessConfirmed = true + watchdogAttempt = 0 + } + safeOn("device:unpaired") { Log.w("WebSocketService", "Device not found on server - clearing credentials") config.clearDeviceCredentials() @@ -380,6 +419,19 @@ class WebSocketService : Service() { } } + // v4 client identity block — additive, canonical snake_case (same field shape as the .wgt and + // /player), piggybacked on the register message the client already sends. Backward-compatible: + // an old server ignores unknown fields. Capture-don't-act — the server stores it; no client + // logic is built on it here. + private fun JSONObject.putIdentity() { + try { + put("client_type", "apk") + put("client_version", deviceInfo.getAppVersion()) + put("platform", "Android " + android.os.Build.VERSION.RELEASE) + put("contract_version", "v4") + } catch (e: Throwable) { Log.w("WebSocketService", "identity: ${e.message}") } + } + private fun register() { try { val data = JSONObject().apply { @@ -398,6 +450,7 @@ class WebSocketService : Service() { } 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}") } + putIdentity() } socket?.emit("device:register", data) } catch (e: Throwable) { @@ -417,18 +470,60 @@ class WebSocketService : Service() { heartbeatCount = 0 heartbeatRunnable = object : Runnable { override fun run() { + // A watchdog reconnect (below) tears down + restarts the heartbeat; if this is a + // stale runnable superseded by that restart, stop — never let two loops run. + if (heartbeatRunnable !== this) return sendHeartbeat() heartbeatCount++ // Every 4th heartbeat (60s), request a fresh playlist if (heartbeatCount % 4 == 0) { requestPlaylistRefresh() } + // v4 liveness watchdog: runs on the heartbeat tick (i.e. only while we've been + // SENDING heartbeats). If it detects+acts on a half-open socket it tears this loop + // down and a fresh one starts on re-register, so do NOT reschedule this one. + if (checkHalfOpenAndReconnect()) return handler.postDelayed(this, 15000) // Every 15 seconds } } handler.post(heartbeatRunnable!!) } + /** + * v4 half-open detection. On a HALF-OPEN socket Socket.IO still reports connected()==true + * (its own auto-reconnect can't see server-silence), so we detect it here: armed (saw an ack) + * + connected + silent past the jittered threshold. Returns true iff it triggered a reconnect + * (so the heartbeat loop stops). The exponential backoff gate spaces repeated attempts so the + * watchdog can't become the flood #143/#149 fixed. No status/health poll — load is read from + * our own ack-silence. + */ + private fun checkHalfOpenAndReconnect(): Boolean { + val connected = socket?.connected() == true + val silenceMs = SystemClock.elapsedRealtime() - lastServerMessageAt + if (!LivenessWatchdog.isHalfOpen(livenessConfirmed, connected, silenceMs, currentThresholdMs)) return false + val sinceAttempt = SystemClock.elapsedRealtime() - lastWatchdogAttemptAt + val backoff = LivenessWatchdog.backoffMs(watchdogAttempt + 1, Random.nextDouble()) + if (!LivenessWatchdog.mayReconnectNow(sinceAttempt, backoff)) return false + watchdogAttempt++ + lastWatchdogAttemptAt = SystemClock.elapsedRealtime() + Log.w("WebSocketService", "v4 watchdog: HALF-OPEN (silent ${silenceMs}ms > ${currentThresholdMs}ms, attempt=$watchdogAttempt) — teardown+reconnect (#148)") + reconnectHalfOpen() + return true + } + + /** + * Teardown-before-reopen (#148) for a half-open socket. disconnect() kills the dead socket AND + * its listeners/auto-reconnect FIRST (so we don't race Socket.IO's own reconnect), then + * connect() — with socket=null + socketActive=false — opens exactly ONE fresh socket via the + * ConnectionGuard. @Synchronized so it can't interleave with a racing connect()/openSocket(). + * The watchdog LAYERS ON the existing #148 guard; it does not replace it. + */ + @Synchronized + private fun reconnectHalfOpen() { + disconnect() + connect() + } + fun requestPlaylistRefresh() { if (socket?.connected() != true || config.deviceId.isEmpty()) return Log.i("WebSocketService", "Requesting playlist refresh") @@ -438,6 +533,7 @@ class WebSocketService : Service() { val token = config.deviceToken if (token.isNotEmpty()) put("device_token", token) try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") } + putIdentity() } socket?.emit("device:register", data) } catch (e: Throwable) { @@ -695,6 +791,19 @@ class WebSocketService : Service() { private set override fun onDestroy() { + // Exit-signal contract v1 — 'clean_exit' (BEST-EFFORT). onDestroy runs ONLY on cooperative + // teardown (stopService/unbind/memory-reclaim-with-grace); a force-stop / MDM-uninstall / SIGKILL + // skips it entirely -> the server infers 'silent' (correct — not misclassified). Try the still- + // live socket first, then a bounded blocking beacon (the reliable path); the server dedups. + try { + if (socket?.connected() == true && config.deviceId.isNotEmpty()) { + socket?.emit("device:exit", JSONObject().apply { + put("device_id", config.deviceId); put("reason", "clean_exit"); put("detail", "onDestroy") + }) + } + } catch (e: Throwable) { /* never let the last gasp block teardown */ } + val ctx = applicationContext + Thread { ExitSignal.send(ctx, "clean_exit", "onDestroy") }.apply { start(); try { join(1500) } catch (e: InterruptedException) { /* proceed with teardown */ } } wakeLock?.let { if (it.isHeld) it.release() } disconnect() super.onDestroy() diff --git a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt index 6f0ac58..553a857 100644 --- a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt +++ b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt @@ -174,7 +174,7 @@ class DeviceInfo(private val context: Context) { return dm.widthPixels to dm.heightPixels } - private fun getAppVersion(): String { + fun getAppVersion(): String { return try { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0" } catch (e: Exception) { diff --git a/android/app/src/test/java/com/remotedisplay/player/data/CacheValidationTest.kt b/android/app/src/test/java/com/remotedisplay/player/data/CacheValidationTest.kt new file mode 100644 index 0000000..6fa26f7 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/data/CacheValidationTest.kt @@ -0,0 +1,30 @@ +package com.remotedisplay.player.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Root-2: the partial/truncated-download detection rule that keeps a broken file out of the cache. */ +class CacheValidationTest { + + @Test fun `full download with known length is complete`() { + assertTrue(CacheValidation.isComplete(bytesWritten = 100, expectedBytes = 100)) + } + + @Test fun `truncated download (fewer bytes than declared) is INCOMPLETE`() { + assertFalse(CacheValidation.isComplete(bytesWritten = 40, expectedBytes = 100)) + } + + @Test fun `over-read (more than declared) is treated as INCOMPLETE, not promoted`() { + assertFalse(CacheValidation.isComplete(bytesWritten = 150, expectedBytes = 100)) + } + + @Test fun `unknown length (chunked, -1) with bytes falls back to complete`() { + assertTrue(CacheValidation.isComplete(bytesWritten = 100, expectedBytes = -1)) + } + + @Test fun `zero bytes is never complete`() { + assertFalse(CacheValidation.isComplete(bytesWritten = 0, expectedBytes = -1)) + assertFalse(CacheValidation.isComplete(bytesWritten = 0, expectedBytes = 100)) + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/data/ContentDownloadTest.kt b/android/app/src/test/java/com/remotedisplay/player/data/ContentDownloadTest.kt new file mode 100644 index 0000000..c9ac893 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/data/ContentDownloadTest.kt @@ -0,0 +1,119 @@ +package com.remotedisplay.player.data + +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.OutputStream +import java.net.ServerSocket +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +/** + * Root-2 REPRODUCE-THEN-PROVE for the "stuck downloading / frozen" caching bug. Each test drives + * the REAL ContentCache.downloadContent against a local HTTP server that reproduces a specific + * failure mode on a HEALTHY socket (the socket is fine — the DOWNLOAD misbehaves), and proves the + * fix: a stalled/trickling download aborts instead of hanging forever, and a truncated body is + * never promoted to the cache (so it can't be played as if whole and wedge the playlist). + * + * The client uses short timeouts so the STALL reproduction is fast; the download/validation logic + * exercised is identical to production (only the timeout VALUES differ — production is + * callTimeout=5min / readTimeout=30s, verified by inspection in ContentCache.defaultClient()). + */ +class ContentDownloadTest { + + private lateinit var dir: java.io.File + private lateinit var cache: ContentCache + private var server: ServerSocket? = null + + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(1, TimeUnit.SECONDS) // a stalled stream aborts in ~1s => fast test + .callTimeout(3, TimeUnit.SECONDS) // hard overall cap backstop + .build() + + @Before fun setUp() { + dir = Files.createTempDirectory("cachetest").toFile() + cache = ContentCache(dir, client) // internal ctor — real logic, no Android Context + } + + @After fun tearDown() { + try { server?.close() } catch (_: Exception) {} + dir.deleteRecursively() + } + + /** Accept ONE connection, drain the request, then let [respond] write a crafted response. */ + private fun serveOnce(respond: (OutputStream) -> Unit): String { + val s = ServerSocket(0) + server = s + Thread { + try { + s.accept().use { sock -> + val reader = sock.getInputStream().bufferedReader() + while (true) { val line = reader.readLine() ?: break; if (line.isEmpty()) break } + respond(sock.getOutputStream()) + } + } catch (_: Exception) { /* client hung up on timeout — expected for the stall case */ } + }.apply { isDaemon = true; start() } + return "http://127.0.0.1:${s.localPort}" + } + + private fun OutputStream.writeHttp(contentLength: Int, body: ByteArray) { + write("HTTP/1.1 200 OK\r\nContent-Length: $contentLength\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray()) + write(body) + flush() + } + + private fun partFiles() = dir.listFiles { _, name -> name.endsWith(".part") }?.toList() ?: emptyList() + + // ---- positive control: a complete download IS cached ---- + @Test fun `complete download is cached with the right size and no leftover part file`() { + val url = serveOnce { it.writeHttp(5, "hello".toByteArray()) } + val file = cache.downloadContent(url, "cidA", "clip.bin") + assertNotNull("a complete download should be cached", file) + assertEquals(5L, file!!.length()) + assertNotNull(cache.getCachedFile("cidA")) + assertTrue("no .part temp should remain", partFiles().isEmpty()) + } + + // ---- REPRODUCE: truncated body (declares 100 bytes, sends 40 then closes) on a healthy socket ---- + @Test fun `truncated download is NOT promoted to the cache — partial detected and discarded`() { + val url = serveOnce { + it.writeHttp(100, ByteArray(40) { 'x'.code.toByte() }) + // close after 40 of the declared 100 bytes -> truncation + } + val file = cache.downloadContent(url, "cidB", "clip.bin") + assertNull("a truncated download must return null (not a usable file)", file) + assertNull("a truncated file must NOT be served as cached", cache.getCachedFile("cidB")) + assertTrue("the partial .part must be cleaned up, not left behind", partFiles().isEmpty()) + } + + // ---- REPRODUCE: a STALLED download (headers + a trickle, then hang) on a healthy socket ---- + @Test fun `stalled download aborts within the timeout instead of hanging forever`() { + val url = serveOnce { + it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray()) + it.write(ByteArray(10)); it.flush() + Thread.sleep(10_000) // hang mid-stream — the OLD client (5min readTimeout) waited here + } + val start = System.currentTimeMillis() + val file = cache.downloadContent(url, "cidC", "clip.bin") + val elapsed = System.currentTimeMillis() - start + assertNull("a stalled download must fail, not hang", file) + assertTrue("must abort quickly via the timeout (was ~$elapsed ms)", elapsed < 5_000) + assertNull(cache.getCachedFile("cidC")) + assertTrue("no partial left behind after a stall", partFiles().isEmpty()) + } + + // ---- prefix cross-match guard: an id that prefixes another must not match ---- + @Test fun `getCachedFile does not cross-match an id that is a prefix of another`() { + serveOnce { it.writeHttp(3, "abc".toByteArray()) }.let { url -> + assertNotNull(cache.downloadContent(url, "abc", "x.bin")) + } + assertNotNull(cache.getCachedFile("abc")) + assertNull("id 'ab' must NOT match cached 'abc.x'", cache.getCachedFile("ab")) + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/data/DownloadCoordinatorTest.kt b/android/app/src/test/java/com/remotedisplay/player/data/DownloadCoordinatorTest.kt new file mode 100644 index 0000000..62bea17 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/data/DownloadCoordinatorTest.kt @@ -0,0 +1,194 @@ +package com.remotedisplay.player.data + +import com.remotedisplay.player.player.PlaylistSelection +import com.remotedisplay.player.service.ConnectionGuard +import com.remotedisplay.player.service.LivenessWatchdog +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.OutputStream +import java.net.ServerSocket +import java.nio.file.Files +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Reproduce-then-prove for the background-download × reconnect A-bucket bug. Drives the REAL + * DownloadCoordinator + ContentCache against a local server that can be GATED (held mid-response) + * so a download is genuinely in flight when a reconnect is simulated underneath it. + */ +class DownloadCoordinatorTest { + private lateinit var dir: java.io.File + private lateinit var cache: ContentCache + private lateinit var executor: ExecutorService + private lateinit var coord: DownloadCoordinator + private var server: ServerSocket? = null + + private val requests = AtomicInteger(0) + private val acks = Collections.synchronizedList(mutableListOf()) // "id:status" + @Volatile private var socketUp = true + @Volatile private var clock = 0L + private var baseUrl = "" + + private val client = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS).readTimeout(2, TimeUnit.SECONDS).callTimeout(4, TimeUnit.SECONDS).build() + + @Before fun setUp() { + dir = Files.createTempDirectory("coord").toFile() + cache = ContentCache(dir, client) + executor = Executors.newFixedThreadPool(2) + requests.set(0); acks.clear(); socketUp = true; clock = 0L + coord = DownloadCoordinator(cache, { baseUrl }, { socketUp }, { c, s -> acks.add("$c:$s") }, executor, { clock }) + } + + @After fun tearDown() { + try { coord.shutdown() } catch (_: Exception) {} + try { executor.shutdownNow() } catch (_: Exception) {} + server?.close(); dir.deleteRecursively() + } + + // A server that accepts connections in a loop; each: counts, fires `arrived` on the 1st, awaits + // `gate` (if any), then writes `respond` and closes. Multiple connections are accepted, so a + // buggy DUPLICATE download shows up as requests==2. + private fun serve(gate: CountDownLatch?, arrived: CountDownLatch?, respond: (OutputStream) -> Unit) { + val s = ServerSocket(0); server = s; baseUrl = "http://127.0.0.1:${s.localPort}" + Thread { + try { + while (!s.isClosed) { + val sock = s.accept() + if (requests.incrementAndGet() == 1) arrived?.countDown() + Thread { + try { + val r = sock.getInputStream().bufferedReader() + while (true) { val l = r.readLine() ?: break; if (l.isEmpty()) break } + gate?.await(5, TimeUnit.SECONDS) + respond(sock.getOutputStream()); sock.close() + } catch (_: Exception) {} + }.apply { isDaemon = true; start() } + } + } catch (_: Exception) {} + }.apply { isDaemon = true; start() } + } + private fun full(body: String): (OutputStream) -> Unit = { o -> + o.write("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n".toByteArray()); o.write(body.toByteArray()); o.flush() + } + private fun status(line: String): (OutputStream) -> Unit = { o -> + o.write("HTTP/1.1 $line\r\nContent-Length: 0\r\n\r\n".toByteArray()); o.flush() + } + private fun partFiles() = dir.listFiles { _, n -> n.endsWith(".part") }?.size ?: 0 + private fun await(l: CountDownLatch, what: String) = assertTrue("timeout waiting for $what", l.await(5, TimeUnit.SECONDS)) + private fun waitAck(key: String) { for (i in 0..60) { if (acks.contains(key)) return; Thread.sleep(100) }; fail("no ack '$key' (got $acks)") } + + // ===== THE REPRODUCE-THEN-PROVE: single-flight survives a reconnect mid-download ===== + @Test fun `reconnect mid-download starts NO duplicate — one request, in-flight fetch completes cleanly`() { + val gate = CountDownLatch(1); val arrived = CountDownLatch(1) + serve(gate, arrived, full("HELLObytes")) + coord.ensure("X", "v.bin") // download starts, blocks on the gate — genuinely in flight + await(arrived, "the download to reach the server") + assertTrue("X is in flight", coord.isInFlight("X")) + // SIMULATE THE RECONNECT: re-register drives more sweeps for the SAME content + coord.ensure("X", "v.bin"); coord.ensure("X", "v.bin") + Thread.sleep(300) // give any (buggy) duplicate time to connect + assertEquals("single-flight: exactly ONE request despite reconnect re-sweeps", 1, requests.get()) + gate.countDown() // let the in-flight download finish on its own connection + waitAck("X:ready") + assertNotNull("the in-flight download completes and is cached (not orphaned/abandoned)", cache.getCachedFile("X")) + assertEquals("no partial left", 0, partFiles()) + assertFalse("no longer in flight after completion", coord.isInFlight("X")) + } + + // ===== partial safety: a reconnect-truncated download is detected + not swapped ===== + @Test fun `reconnect-truncated download is detected as partial, acked failed, never swapped`() { + serve(null, null) { o -> // declares 100, sends 40 then closes — as a mid-fetch network drop would + o.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray()); o.write(ByteArray(40)); o.flush() + } + coord.ensure("Y", "v.bin"); waitAck("Y:failed") + assertNull("a truncated file must NOT be cached/swapped", cache.getCachedFile("Y")) + assertEquals("no partial left behind", 0, partFiles()) + } + + // ===== permanent failure: backoff, no retry storm ===== + @Test fun `a permanently-failing download backs off instead of storming`() { + serve(null, null, status("404 Not Found")) + coord.ensure("Z", "v.bin"); waitAck("Z:failed") + val after1 = requests.get() + repeat(5) { coord.ensure("Z", "v.bin") }; Thread.sleep(300) // clock unchanged -> within backoff + assertEquals("within backoff, NO new attempts (no storm)", after1, requests.get()) + clock = 20_000 // past the first 15s backoff step + coord.ensure("Z", "v.bin"); Thread.sleep(300) + assertEquals("after backoff elapses, exactly one more attempt", after1 + 1, requests.get()) + } + + // ===== socket-down DEFER (ownership: watchdog owns recovery) ===== + @Test fun `socket down defers the download to the reconnect, then downloads when back`() { + serve(null, null, full("data")) + socketUp = false + coord.ensure("W", "v.bin"); Thread.sleep(300) + assertEquals("no download while the socket is down", 0, requests.get()) + socketUp = true + coord.ensure("W", "v.bin"); waitAck("W:ready") + assertNotNull("downloads once the socket is back", cache.getCachedFile("W")) + } + + // ===== teardown cancels in-flight work (no orphan / no Activity pin) ===== + @Test fun `shutdown cancels in-flight download and clears state`() { + val gate = CountDownLatch(1); val arrived = CountDownLatch(1) + serve(gate, arrived, full("data")) + coord.ensure("Q", "v.bin"); await(arrived, "in-flight download") + coord.shutdown() + assertFalse("in-flight guard cleared on shutdown (no orphan)", coord.isInFlight("Q")) + Thread.sleep(200) + assertNull("nothing cached — the download was cancelled, not abandoned mid-promote", cache.getCachedFile("Q")) + } + + // ===== 206 Partial Content is refused (never promoted as whole) ===== + @Test fun `a 206 partial-content response is refused, not promoted`() { + serve(null, null) { o -> // 206 with a matching partial length would pass a naive byte-count check + o.write("HTTP/1.1 206 Partial Content\r\nContent-Length: 3\r\n\r\n".toByteArray()); o.write("abc".toByteArray()); o.flush() + } + coord.ensure("P", "v.bin"); waitAck("P:failed") + assertNull("a 206 partial must not be cached as complete", cache.getCachedFile("P")) + } + + // ===== INTEGRATED RE-SOAK: playing -> bg download -> reconnect mid-download -> resume ===== + @Test fun `re-soak — one socket, no orphaned download, screen never blanks, download resumes`() { + val onlyZeroReady: (Int) -> Boolean = { it == 0 } // item 0 cached & playing; item 1 downloading + val threshold = LivenessWatchdog.thresholdMs(0.5) + val gate = CountDownLatch(1); val arrived = CountDownLatch(1) + serve(gate, arrived, full("item1data")) + + // playing item 0; item 1's background download is in flight + coord.ensure("item1", "v.bin"); await(arrived, "item1 download") + assertEquals("screen keeps playing item 0 (skips the still-downloading item 1)", 0, + PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady)) + + // server goes silent -> watchdog fires (its own signal); screen must NOT blank + assertTrue(LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 50_000, thresholdMs = threshold)) + assertEquals(PlaylistSelection.NonePlayable.KEEP_CURRENT, PlaylistSelection.whenNonePlayable(hasContentOnScreen = true)) + + // watchdog reconnect -> exactly ONE socket via ConnectionGuard; the re-sweep does NOT dup the download + assertTrue(ConnectionGuard.shouldOpenNewSocket(hasSocket = false, sameUrl = true, socketActive = false)) + coord.ensure("item1", "v.bin"); Thread.sleep(200) + assertEquals("reconnect re-sweep did not orphan/duplicate the in-flight download", 1, requests.get()) + assertEquals("screen still shows item 0 across the reconnect", 0, + PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady)) + + // download RESUMES (its own connection was never torn down) and completes cleanly + gate.countDown(); waitAck("item1:ready") + assertNotNull("item 1 downloaded (not orphaned) and is now swappable", cache.getCachedFile("item1")) + assertFalse(coord.isInFlight("item1")) + assertEquals("now both items ready -> screen advances to item 1 (only fully-valid content)", 1, + PlaylistSelection.nextPlayableIndex(2, 0) { true }) + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/player/PlaylistSelectionTest.kt b/android/app/src/test/java/com/remotedisplay/player/player/PlaylistSelectionTest.kt new file mode 100644 index 0000000..d4055bd --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/player/PlaylistSelectionTest.kt @@ -0,0 +1,60 @@ +package com.remotedisplay.player.player + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Screen-resilience (FIX 2): a pending/failed/stalled content download must NEVER blank or freeze + * a screen that is showing content. These prove the SELECTION rules that guarantee it — unready + * items are skipped (the player keeps looping what it has), and "nothing playable" keeps the + * current content whenever anything is on screen. + */ +class PlaylistSelectionTest { + + // helper: items 0..n-1, `ready` are the indices whose content is downloaded/available + private fun readyPredicate(vararg ready: Int): (Int) -> Boolean = { it in ready.toSet() } + + // ===== the KEY viewer scenario: playing item 0, item 1's download is stalled ===== + @Test fun `a stalled download of the NEXT item does not interrupt current content — it loops what it has`() { + // playlist [0 ready, 1 NOT ready(downloading)]; currently playing 0. Advancing must SKIP 1 + // and come back to 0, so the viewer keeps seeing content (no blank, no "Downloading…"). + val idx = PlaylistSelection.nextPlayableIndex(size = 2, from = 0, isPlayable = readyPredicate(0)) + assertEquals("must skip the un-downloaded item and keep playing item 0", 0, idx) + } + + @Test fun `once the download completes the next item is picked up on the following advance`() { + // now item 1 is downloaded too -> advancing from 0 swaps to 1 (only fully-ready content). + val idx = PlaylistSelection.nextPlayableIndex(size = 2, from = 0, isPlayable = readyPredicate(0, 1)) + assertEquals(1, idx) + } + + // ===== partial-file safety: an un-ready (partial/corrupt) item is never selected ===== + @Test fun `an un-ready (partial-download) item is never chosen to play`() { + // items [0 ready, 1 partial/not-ready, 2 ready] -> selection never returns 1. + assertEquals(0, PlaylistSelection.firstPlayableIndex(3, readyPredicate(0, 2))) + assertEquals(2, PlaylistSelection.nextPlayableIndex(3, 0, readyPredicate(0, 2))) + assertEquals(0, PlaylistSelection.nextPlayableIndex(3, 2, readyPredicate(0, 2))) + } + + // ===== nothing downloaded yet ===== + @Test fun `no item ready returns -1 (nothing to play)`() { + assertEquals(-1, PlaylistSelection.firstPlayableIndex(3, readyPredicate())) + assertEquals(-1, PlaylistSelection.nextPlayableIndex(3, 1, readyPredicate())) + } + + // ===== the invariant: never blank while content is on screen ===== + @Test fun `nothing-playable KEEPS current content whenever something is on screen (never blanks)`() { + assertEquals(PlaylistSelection.NonePlayable.KEEP_CURRENT, + PlaylistSelection.whenNonePlayable(hasContentOnScreen = true)) + } + + @Test fun `nothing-playable shows the defined waiting state only when nothing is displayed yet`() { + assertEquals(PlaylistSelection.NonePlayable.SHOW_WAITING, + PlaylistSelection.whenNonePlayable(hasContentOnScreen = false)) + } + + // ===== single downloaded item loops (doesn't blank waiting for others) ===== + @Test fun `a single downloaded item loops instead of blanking`() { + assertEquals(0, PlaylistSelection.nextPlayableIndex(1, 0, readyPredicate(0))) + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/service/AssemblyInteractionTest.kt b/android/app/src/test/java/com/remotedisplay/player/service/AssemblyInteractionTest.kt new file mode 100644 index 0000000..d7173b6 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/service/AssemblyInteractionTest.kt @@ -0,0 +1,172 @@ +package com.remotedisplay.player.service + +import com.remotedisplay.player.data.ContentCache +import com.remotedisplay.player.player.PlaylistSelection +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.OutputStream +import java.net.ServerSocket +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +/** + * APK VERIFY pass — the ASSEMBLY soak. Per-part tests prove the pieces; this proves the + * caching-state-machine × liveness-watchdog INTERACTION (the novel APK-only behavior) does not + * step on itself, driving the REAL ContentCache download + the REAL LivenessWatchdog decisions + * through the lifecycle sequence. + * + * Scope note (honest): the Handler/Looper/Socket.IO runtime can't run in a plain JVM unit test + * (no Robolectric in this module), so socket-teardown/timer wiring is verified by code-trace + + * ConnectionGuard's decisions; the DOWNLOAD path and every WATCHDOG DECISION here are the real + * production code exercised with real time. + */ +class AssemblyInteractionTest { + + private lateinit var dir: java.io.File + private lateinit var cache: ContentCache + private var server: ServerSocket? = null + private val client = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(1, TimeUnit.SECONDS) + .callTimeout(3, TimeUnit.SECONDS) + .build() + + @Before fun setUp() { + dir = Files.createTempDirectory("assembly").toFile() + cache = ContentCache(dir, client) + } + + @After fun tearDown() { + try { server?.close() } catch (_: Exception) {} + dir.deleteRecursively() + } + + private fun serveOnce(respond: (OutputStream) -> Unit): String { + val s = ServerSocket(0); server = s + Thread { + try { + s.accept().use { sock -> + val r = sock.getInputStream().bufferedReader() + while (true) { val l = r.readLine() ?: break; if (l.isEmpty()) break } + respond(sock.getOutputStream()) + } + } catch (_: Exception) {} + }.apply { isDaemon = true; start() } + return "http://127.0.0.1:${s.localPort}" + } + private fun serveComplete(body: String) = serveOnce { + it.write("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n".toByteArray()) + it.write(body.toByteArray()); it.flush() + } + private fun serveStall() = serveOnce { + it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray()) + it.write(ByteArray(10)); it.flush(); Thread.sleep(10_000) + } + private fun partFiles() = dir.listFiles { _, n -> n.endsWith(".part") }?.toList() ?: emptyList() + + // ===== VERIFY 1: reconnect ownership — watchdog routes through ConnectionGuard (single owner) ===== + @Test fun `watchdog reconnect opens exactly one socket via ConnectionGuard, never its own`() { + // reconnectHalfOpen() = disconnect() [socket=null, socketActive=false] then connect(), and + // connect() consults ConnectionGuard. After teardown -> open exactly ONE. + assertTrue(ConnectionGuard.shouldOpenNewSocket(hasSocket = false, sameUrl = true, socketActive = false)) + // and a live/self-healing socket is REUSED (single-owner: never a second socket alongside). + assertFalse(ConnectionGuard.shouldOpenNewSocket(hasSocket = true, sameUrl = true, socketActive = true)) + } + + // ===== VERIFY 3 CHECK A: a caching stall/retry must NOT look like server-silence ===== + @Test fun `a stalled download does NOT trip the watchdog while the socket keeps getting acks`() { + // Real architecture: downloads run on their own thread; socket liveness is refreshed by + // heartbeat-acks (any inbound) every 15s, INDEPENDENT of download activity. Walk a 60s + // stall; an ack lands every 15s. Silence must never cross the (jitter-floor) threshold. + val threshold = LivenessWatchdog.thresholdMs(0.0) // 35s — the tightest (worst-case) threshold + var lastServerMs = 0L + var spurious = false + for (t in 0..60_000 step 1_000) { + if (t % 15_000 == 0) lastServerMs = t.toLong() // ack refresh — decoupled from the download + if (LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = t - lastServerMs, thresholdMs = threshold)) + spurious = true + } + assertFalse("a download stall must NOT cause a watchdog reconnect while the socket is healthy", spurious) + } + + // ===== VERIFY 3 CHECK B: server-silence DURING a download -> watchdog fires; download unharmed ===== + @Test fun `server silence during a download fires the watchdog AND the download state stays consistent`() { + // The watchdog (server-silence) and the download (its own thread + .part file) are separate + // subsystems: the watchdog tears down the SOCKET, never the download or its files. + val threshold = LivenessWatchdog.thresholdMs(0.5) // 45s + assertTrue("50s of server silence is half-open", LivenessWatchdog.isHalfOpen(true, true, 50_000, threshold)) + val f = cache.downloadContent(serveComplete("payload!"), "cidB", "v.bin") + assertNotNull("the download completes independently of a socket reconnect", f) + assertEquals(8L, f!!.length()) + assertTrue("no partial left — download files untouched by any reconnect", partFiles().isEmpty()) + } + + // ===== VERIFY 3 CHECK C: no double-action — retry then success leaves ONE consistent file ===== + @Test fun `caching-timeout retry and watchdog do not double-act — retry yields one consistent file`() { + // 1st attempt stalls -> caching timeout -> null, partial cleaned (NOT a watchdog concern). + assertNull(cache.downloadContent(serveStall(), "cidC", "v.bin")) + assertTrue("stall leaves no orphan .part", partFiles().isEmpty()) + assertNull("stall does not promote a partial", cache.getCachedFile("cidC")) + // retry (healthy) -> exactly one cached file, no duplicates/orphans. + val f = cache.downloadContent(serveComplete("retry-ok"), "cidC", "v.bin") + assertNotNull(f); assertEquals(8L, f!!.length()) + assertEquals("exactly one cached artifact for the id", 1, + dir.listFiles { _, n -> n.startsWith("cidC.") }?.size ?: 0) + } + + // ===== VERIFY 4: degrade-safe + the deferred ack-gap window ===== + @Test fun `degrade-safe — an ack-less server never arms the watchdog, even when very silent`() { + assertFalse(LivenessWatchdog.isHalfOpen(armed = false, connected = true, silenceMs = 10 * 60_000, thresholdMs = 45_000)) + } + + // ===== FIX 1 + FIX 2 re-soak: through the whole sequence the SCREEN NEVER BLANKS ===== + @Test fun `re-soak — stall then ack-silence then watchdog reconnect then resume, screen never blanks`() { + // Device is PLAYING item 0 (downloaded); item 1's download is stalled. Walk every stage. + val onlyZeroReady: (Int) -> Boolean = { it == 0 } + val bothReady: (Int) -> Boolean = { true } + val threshold = LivenessWatchdog.thresholdMs(0.5) + + // 1) healthy, item 1 download STALLS on a live socket -> advancing SKIPS 1 and keeps 0 + // (a playable index is always found, so the screen shows content — never blanks), and + // the stall does NOT trip the watchdog. + assertEquals("skip the stalled item, keep playing 0", 0, + PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady)) + assertFalse("download stall on a live socket must not trip the watchdog", + LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 5_000, thresholdMs = threshold)) + + // 2) server goes SILENT -> the watchdog fires on ITS signal, but the screen keeps content + assertTrue(LivenessWatchdog.isHalfOpen(true, true, 50_000, threshold)) + assertEquals("a reconnect must never blank a screen showing content", + PlaylistSelection.NonePlayable.KEEP_CURRENT, PlaylistSelection.whenNonePlayable(hasContentOnScreen = true)) + + // 3) watchdog reconnect -> EXACTLY ONE socket via ConnectionGuard (single owner) + assertTrue(ConnectionGuard.shouldOpenNewSocket(hasSocket = false, sameUrl = true, socketActive = false)) + + // 4) during the reconnect window, item 1 still not ready (downloads deferred, FIX 1) -> + // advancing still finds item 0 -> screen still shows content + assertEquals(0, PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady)) + + // 5) reconnected + item 1 now fully downloaded -> swaps forward to item 1 (only ever swap + // to fully-valid content). At no stage did selection return -1 while 0 was ready, so the + // screen was never blanked. + assertEquals(1, PlaylistSelection.nextPlayableIndex(2, 0, bothReady)) + } + + @Test fun `ack-gap window — reconnecting-not-yet-re-registered does not false-fire`() { + // openSocket resets armed=false + lastServerMessageAt=now. Before the first post-reconnect + // ack, armed=false so NO fire even though the OLD connection's silence was large... + assertFalse("must not fire before the first post-reconnect ack", + LivenessWatchdog.isHalfOpen(armed = false, connected = true, silenceMs = 999_999, thresholdMs = 45_000)) + // ...and once any-inbound (the re-register response) refreshes liveness and an ack arms it, + // a fresh connection with small silence is healthy (still no fire). + assertFalse("healthy post-reconnect (armed, low silence) does not fire", + LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 500, thresholdMs = 45_000)) + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/service/LivenessWatchdogTest.kt b/android/app/src/test/java/com/remotedisplay/player/service/LivenessWatchdogTest.kt new file mode 100644 index 0000000..e9ff284 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/service/LivenessWatchdogTest.kt @@ -0,0 +1,76 @@ +package com.remotedisplay.player.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * v4 liveness-contract conformance for the pure watchdog decision logic. Proves: + * - degrade-safe (never fires unarmed) and the connected+silent half-open condition, + * - the anti-thundering-herd guarantees: threshold jitter (45s ± up to 10s) and exponential + * reconnect backoff with jitter (1,2,4,8,16… capped, ±20%) are PRESENT (not fixed values). + */ +class LivenessWatchdogTest { + + // ---- degrade-safe + half-open decision ---- + + @Test fun `unarmed never fires — degrade-safe against an ack-less server`() { + // Even a connected socket silent well past threshold must NOT reconnect until armed by an ack. + assertFalse(LivenessWatchdog.isHalfOpen(armed = false, connected = true, silenceMs = 999_999, thresholdMs = 45_000)) + } + + @Test fun `disconnected never fires — that is Socket_IO's job, not the watchdog's`() { + assertFalse(LivenessWatchdog.isHalfOpen(armed = true, connected = false, silenceMs = 999_999, thresholdMs = 45_000)) + } + + @Test fun `armed + connected + silent past threshold IS half-open`() { + assertTrue(LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 46_000, thresholdMs = 45_000)) + } + + @Test fun `armed + connected but within threshold is healthy`() { + assertFalse(LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 30_000, thresholdMs = 45_000)) + } + + // ---- anti-herd: threshold jitter 45s ± up to 10s ---- + + @Test fun `threshold is centered on 45s and stays within ±10s`() { + assertEquals(45_000L, LivenessWatchdog.thresholdMs(0.5)) + assertEquals(35_000L, LivenessWatchdog.thresholdMs(0.0)) + assertEquals(54_999L, LivenessWatchdog.thresholdMs(0.99995)) // ~55s upper bound (exclusive) + for (i in 0..100) { + val t = LivenessWatchdog.thresholdMs(i / 100.0) + assertTrue("threshold $t out of [35s,55s)", t in 35_000..55_000) + } + } + + @Test fun `threshold has jitter — different rands give different thresholds (not fixed)`() { + assertTrue(LivenessWatchdog.thresholdMs(0.1) != LivenessWatchdog.thresholdMs(0.9)) + } + + // ---- anti-herd: exponential backoff 1,2,4,8,16… capped, ±20% jitter ---- + + @Test fun `backoff doubles per attempt then saturates at the cap`() { + // rand=0.5 => no jitter, so we see the exact base curve. + assertEquals(1_000L, LivenessWatchdog.backoffMs(1, 0.5)) + assertEquals(2_000L, LivenessWatchdog.backoffMs(2, 0.5)) + assertEquals(4_000L, LivenessWatchdog.backoffMs(3, 0.5)) + assertEquals(8_000L, LivenessWatchdog.backoffMs(4, 0.5)) + assertEquals(16_000L, LivenessWatchdog.backoffMs(5, 0.5)) + assertEquals(30_000L, LivenessWatchdog.backoffMs(6, 0.5)) // 32s -> capped at 30s + assertEquals(30_000L, LivenessWatchdog.backoffMs(50, 0.5)) // no overflow at large attempts + } + + @Test fun `backoff jitter is ±20% of the step and present (not fixed)`() { + // attempt 5 base = 16s; ±20% => [12.8s, 19.2s]. + assertEquals(12_800L, LivenessWatchdog.backoffMs(5, 0.0)) // -20% + assertEquals(19_200L, LivenessWatchdog.backoffMs(5, 1.0)) // +20% + assertTrue(LivenessWatchdog.backoffMs(5, 0.2) != LivenessWatchdog.backoffMs(5, 0.8)) + } + + @Test fun `mayReconnectNow gates on the backoff having elapsed`() { + assertTrue(LivenessWatchdog.mayReconnectNow(msSinceLastAttempt = 2_000, backoffMs = 1_000)) + assertTrue(LivenessWatchdog.mayReconnectNow(msSinceLastAttempt = 1_000, backoffMs = 1_000)) + assertFalse(LivenessWatchdog.mayReconnectNow(msSinceLastAttempt = 500, backoffMs = 1_000)) + } +} diff --git a/frontend/css/main.css b/frontend/css/main.css index 432724f..4edacf1 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -263,6 +263,9 @@ body { .status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); } .status-dot.offline { background: var(--danger); } .status-dot.provisioning { background: var(--warning); animation: pulse 2s infinite; } +/* v4 liveness (server-derived): healthy=green, degraded=amber+pulse (reconnecting), offline=red (above) */ +.status-dot.healthy { background: var(--success); box-shadow: 0 0 6px var(--success); } +.status-dot.degraded { background: var(--warning); box-shadow: 0 0 6px var(--warning); animation: pulse 2s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } @@ -418,6 +421,19 @@ body { font-size: 11px; font-weight: 500; } +/* Device cards render the reused liveness pill (.device-status-badge from device-detail), which brings + its own bg/padding/shape — so neutralize the dark wrapper for those. Wall cards keep the dark pill + above for their "NxN wall" label (they share .device-card-status but have no .is-liveness). */ +.device-card-status.is-liveness { + background: none; + backdrop-filter: none; + padding: 0; + border-radius: 0; +} +/* Lift the list pill off the screenshot so it stays legible over any image. */ +.device-card-status.is-liveness .device-status-badge { + box-shadow: 0 1px 4px rgba(0,0,0,0.6); +} .device-card-select { position: absolute; @@ -803,6 +819,9 @@ body { .device-status-badge.online { background: var(--success-dim); color: var(--success); } .device-status-badge.offline { background: var(--danger-dim); color: #fca5a5; } .device-status-badge.provisioning { background: var(--warning-dim); color: var(--warning); } +/* v4 liveness (server-derived): healthy=green, degraded=amber (reconnecting), offline reuses .offline above */ +.device-status-badge.healthy { background: var(--success-dim); color: var(--success); } +.device-status-badge.degraded { background: var(--warning-dim); color: var(--warning); } .tabs { display: flex; diff --git a/frontend/index.html b/frontend/index.html index 89c14f9..9a09957 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,7 @@ + diff --git a/frontend/js/api.js b/frontend/js/api.js index 990a616..e275d30 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -36,6 +36,10 @@ export const api = { // no restart. Server enforces via the SNAT-safe identity chain (deviceSocket). blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }), unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }), + // #150: fingerprint-keyed settings snapshots of previously-removed devices (this workspace), + // and the re-adopt action that applies a snapshot onto a newly-paired device. + getRemovedDevices: () => request('/devices/removed'), + reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }), // #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a // device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT). diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 91eeff6..cce3843 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -264,6 +264,26 @@ export default { 'device.playlist.empty_desc': "Add content from your library to this display's playlist.", 'device.playlist_picker.with_count': '{name} — {n} items', 'device.playlist_picker.with_auto': '{name} (auto) — {n} items', + // v4 liveness badge (server-derived 3-state) + 'device.liveness.healthy': 'Healthy', + 'device.liveness.degraded': 'Reconnecting', + 'device.liveness.offline': 'Offline', + // exit-signal contract — manner-of-death annotation on Offline (reliability-aware, §10) + 'device.exit.crashed': 'crashed', + 'device.exit.clean': 'clean exit', + 'device.exit.clean_besteffort': 'clean exit (best-effort)', + 'device.exit.silent': 'silent (no signal)', + 'device.exit.silent_short': 'silent', + // filter drill-in options (sub-dimension of Offline) + 'dashboard.filter.offline_silent': 'Offline · silent', + 'dashboard.filter.offline_crashed': 'Offline · crashed', + 'dashboard.filter.offline_clean': 'Offline · clean exit', + 'dashboard.filter.offline_by_reason': 'Offline by reason', + // honest hover explanations (carry the contract's reliability) + 'device.exit.crashed.tip': 'The app’s own error handler fired — it crashed before dying (our fault).', + 'device.exit.clean.tip': 'An orderly shutdown signal fired — something closed the app cleanly.', + 'device.exit.clean_besteffort.tip': 'Reported an orderly shutdown, but this platform’s signal is best-effort — likely, not certain.', + 'device.exit.silent.tip': 'No exit signal arrived — external or violent termination: power loss, network drop, force-stop, or MDM/kill. The honest catch-all.', // Info cards 'device.info.status': 'Status', 'device.info.ip_address': 'IP Address', @@ -301,6 +321,26 @@ export default { 'device.debug.toggle': 'Debug logging (live)', 'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.', 'device.form.save_settings': 'Save Settings', + // #150 re-adopt: restore a removed device's saved settings onto this one + 'device.readopt.button': 'Restore from removed device…', + 'device.readopt.button_hint': "Apply a previously-removed device's saved settings onto this one (for a re-paired screen whose fingerprint changed)", + 'device.readopt.title': 'Restore settings from a removed device', + 'device.readopt.help': 'Pick a previously-removed device to copy its saved settings onto “{name}”. This overwrites the current settings.', + 'device.readopt.empty': 'No previously-removed devices in this workspace.', + 'device.readopt.unnamed': 'Unnamed device', + 'device.readopt.blocked': 'Blocked', + 'device.readopt.summary_orientation': 'Orientation', + 'device.readopt.summary_timezone': 'Timezone', + 'device.readopt.summary_playlist': 'Playlist', + 'device.readopt.playlist_none': 'none', + 'device.readopt.playlist_removed': '(playlist since deleted)', + 'device.readopt.last_seen': 'Last seen', + 'device.readopt.removed': 'Removed', + 'device.readopt.apply': 'Apply', + 'device.readopt.confirm': 'Apply saved settings from “{source}” onto “{target}”? This overwrites the current settings.', + 'device.readopt.confirm_blocked': 'This device was BLOCKED. Applying it will re-block the target device — it will immediately refuse to connect and the screen will go dark. Continue?', + 'device.readopt.success': 'Settings restored ({orientation})', + 'device.readopt.error': 'Could not restore settings', // Control buttons 'device.ctl.reboot_device': 'Reboot Device', 'device.ctl.screen_off': 'Screen Off', diff --git a/frontend/js/utils.js b/frontend/js/utils.js index cee0fb5..29dbdf1 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -1,9 +1,68 @@ +import { t } from './i18n.js'; + // HTML escape helper — prevents XSS when inserting user data into innerHTML export function esc(str) { if (str == null) return ''; return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } +// v4 liveness badge. The patch4 server derives a 3-state liveness — 'healthy' / 'degraded' +// (temporarily reconnecting) / 'offline' — and emits it as `data.liveness` on dashboard:device-status. +// It is present on SOME emits only (the plain reconnect + disconnect emits, and any device object read +// from the DB, carry just the binary `status`), so we DEGRADE to the binary status when liveness is +// absent — nothing ever renders blank. 'provisioning' is a lifecycle state (never-paired), kept +// distinct from liveness. livenessState() is pure (unit-testable); livenessBadge() adds the i18n label. +const LIVENESS_LABEL_KEY = { + healthy: 'device.liveness.healthy', + degraded: 'device.liveness.degraded', + offline: 'device.liveness.offline', + provisioning: 'dashboard.awaiting_pairing', +}; +export function livenessState(data) { + const lv = data && data.liveness; + if (lv === 'healthy' || lv === 'degraded' || lv === 'offline') return lv; // 3-state signal present + const st = data && data.status; // backward-compat: derive from binary status + if (st === 'provisioning') return 'provisioning'; + if (st === 'online') return 'healthy'; + if (st === 'offline') return 'offline'; + return 'offline'; // unknown / no data yet -> safe default, never blank +} +// Exit-signal contract §8/§10 — honest, reliability-aware manner-of-death sub-label for an Offline +// device. clean_exit is RELIABLE only on the browser /player (pagehide+sendBeacon); best-effort on +// APK/.wgt, so we qualify it there rather than overstate certainty. crashed/silent are labeled plainly. +// Returns null when no reason is known (old data / never went offline) -> plain "Offline". +// short=true -> concise LIST label (drops the parenthetical qualifiers, which the tooltip still carries); +// full (default) -> DETAIL label with the reliability qualifier. Honesty is preserved either way: the +// full meaning lives in the tooltip (both views) and in the detail label. +function offlineReasonLabel(reason, clientType, short) { + if (reason === 'crashed') return t('device.exit.crashed'); + if (reason === 'clean_exit') { + if (short) return t('device.exit.clean'); // list: "clean exit" (tooltip carries best-effort) + return clientType === 'player' ? t('device.exit.clean') : t('device.exit.clean_besteffort'); + } + if (reason === 'silent') return short ? t('device.exit.silent_short') : t('device.exit.silent'); + return null; +} +// Honest hover explanation of the manner of death — carries the contract's reliability (esp. 'silent' +// = external/violent, and best-effort clean_exit) so an operator isn't misled by a terse badge label. +function offlineReasonTip(reason, clientType) { + if (reason === 'crashed') return t('device.exit.crashed.tip'); + if (reason === 'clean_exit') return clientType === 'player' ? t('device.exit.clean.tip') : t('device.exit.clean_besteffort.tip'); + if (reason === 'silent') return t('device.exit.silent.tip'); + return ''; +} +export function livenessBadge(data, opts = {}) { + const state = livenessState(data); + let label = t(LIVENESS_LABEL_KEY[state]); + let title = '', reason = ''; + if (state === 'offline') { // annotate Offline with the manner of death, if known + const r = data && data.offline_reason, ct = data && data.client_type; + const sub = offlineReasonLabel(r, ct, opts.short); + if (sub) { label += ' · ' + sub; title = offlineReasonTip(r, ct); reason = r || ''; } + } + return { state, label, title, reason }; // reason -> data-offline-reason (filter drill-in); '' unless offline+known +} + // Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin' // role to 'platform_admin'. Existing frontend checks still match the old // string; this helper accepts both so we don't have to splatter the array diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 4725611..596c3bc 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -1,7 +1,7 @@ import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; -import { esc } from '../utils.js'; +import { esc, livenessBadge } from '../utils.js'; import { t, tn } from '../i18n.js'; const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; @@ -100,9 +100,8 @@ function renderDeviceCard(device) { ${t('dashboard.no_preview')} ` } -
- - ${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status} +
+ ${(() => { const b = livenessBadge(device, { short: true }); return `${esc(b.label)}`; })()}
${device.status === 'provisioning' && device.pairing_code ? `
@@ -269,10 +268,16 @@ export function render(container) {
- - - + + + + + + + +
@@ -292,13 +297,21 @@ export function render(container) { function filterDevices() { const search = document.getElementById('deviceSearch').value.toLowerCase(); - const status = document.getElementById('deviceFilter').value; + // Compare against the liveness STATE ('healthy'|'degraded'|'offline'), NOT the display label: + // the badge text is now "Healthy"/"Reconnecting"/"Offline", so the old text-vs-'online' compare + // matched nothing and emptied the list. data-liveness carries the state for a robust match. + const filter = document.getElementById('deviceFilter').value; // '' | healthy | degraded | offline | offline: + const reasonDrill = filter.startsWith('offline:') ? filter.slice(8) : null; // drill into a manner-of-death document.querySelectorAll('.device-card').forEach(card => { const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || ''; - const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || ''; + const el = card.querySelector('.device-card-status [data-liveness]'); + const cardState = el?.dataset.liveness || ''; + const cardReason = el?.dataset.offlineReason || ''; const matchSearch = !search || name.includes(search); - const matchStatus = !status || deviceStatus === status; - card.style.display = (matchSearch && matchStatus) ? '' : 'none'; + const matchState = reasonDrill + ? (cardState === 'offline' && cardReason === reasonDrill) // Offline drill-in: liveness AND reason (e.g. silent = MDM-killed set) + : (!filter || cardState === filter); // existing three-state filter — unchanged + card.style.display = (matchSearch && matchState) ? '' : 'none'; }); } @@ -359,10 +372,11 @@ export function render(container) { // Real-time updates statusHandler = (data) => { + const b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`); cards.forEach(card => { const statusEl = card.querySelector('.device-card-status'); - if (statusEl) statusEl.innerHTML = `${data.status}`; + if (statusEl) statusEl.innerHTML = `${esc(b.label)}`; }); }; diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index cc5ef1c..a505c4a 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -1,7 +1,7 @@ import { api } from '../api.js'; import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js'; import { showToast } from '../components/toast.js'; -import { esc } from '../utils.js'; +import { esc, livenessBadge } from '../utils.js'; import { t, tn } from '../i18n.js'; let currentDevice = null; @@ -68,8 +68,10 @@ export function render(container, deviceId) { if (data.device_id !== deviceId) return; const badge = document.querySelector('.device-status-badge'); if (badge) { - badge.className = `device-status-badge ${data.status}`; - badge.textContent = data.status; + const b = livenessBadge(data); // v4: 3-state liveness when present, else binary status + badge.className = `device-status-badge ${b.state}`; + badge.textContent = b.label; + badge.title = b.title || ''; // exit-reason hover (empty for non-offline / no-reason) } if (data.telemetry) updateTelemetryDisplay(data.telemetry); }; @@ -149,7 +151,7 @@ async function loadDevice(deviceId, activeTab = null) {

${device.name}

- ${device.status} + ${(() => { const b = livenessBadge(device); return `${esc(b.label)}`; })()} ${device.owner_name || device.owner_email ? `${t('device.owner_label', { owner: device.owner_name || device.owner_email })}` : ''}
@@ -370,6 +372,7 @@ async function loadDevice(deviceId, activeTab = null) {
+
@@ -644,7 +647,100 @@ function showDevicePreview(device) { }); } -async function setupActions(device) { +// #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and +// apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent +// fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint. +const ORIENT_LABELS = { + 'landscape': 'device.form.orientation.landscape', + 'portrait': 'device.form.orientation.portrait', + 'landscape-flipped': 'device.form.orientation.landscape_flipped', + 'portrait-flipped': 'device.form.orientation.portrait_flipped', +}; +const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape); +const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—'); + +async function showReAdoptModal(device) { + let snapshots, playlists; + try { + [snapshots, playlists] = await Promise.all([ + api.getRemovedDevices(), + api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist + ]); + } catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; } + + const plById = new Map((playlists || []).map(p => [p.id, p.name])); + const playlistLabel = (s) => !s.playlist_id + ? t('device.readopt.playlist_none') + : (plById.get(s.playlist_id) || t('device.readopt.playlist_removed')); + + const rowsHtml = (snapshots || []).map((s, i) => { + const blockedBadge = s.blocked + ? `${t('device.readopt.blocked')}` + : ''; + // Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only. + const fpShort = (s.fingerprint || '').slice(0, 8); + return ` +
+
+
${esc(s.device_name || t('device.readopt.unnamed'))}${blockedBadge}
+
+ ${t('device.readopt.summary_orientation')}: ${esc(orientLabel(s.orientation))} +  ·  ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')} +  ·  ${t('device.readopt.summary_playlist')}: ${esc(playlistLabel(s))} +
+
+ ${t('device.readopt.last_seen')}: ${esc(fmtTs(s.last_seen))}  ·  ${t('device.readopt.removed')}: ${esc(fmtTs(s.removed_at))} +
+
+ +
`; + }).join(''); + + const emptyHtml = `
${t('device.readopt.empty')}
`; + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.querySelector('#readoptClose').onclick = close; + overlay.onclick = (e) => { if (e.target === overlay) close(); }; + + overlay.querySelectorAll('.readopt-apply').forEach((btn) => { + btn.addEventListener('click', async () => { + const s = snapshots[parseInt(btn.dataset.i, 10)]; + let msg = t('device.readopt.confirm', { source: s.device_name || t('device.readopt.unnamed'), target: device.name || '' }); + if (s.blocked) msg += '\n\n⚠ ' + t('device.readopt.confirm_blocked'); // explicit: target will go dark + if (!confirm(msg)) return; + btn.disabled = true; + try { + await api.reAdoptDevice(device.id, s.fingerprint); + showToast(t('device.readopt.success', { orientation: orientLabel(s.orientation) }), 'success'); + close(); + loadDevice(device.id); // refresh so restored orientation/name/etc show immediately + } catch (err) { + // Server messages: 404 no snapshot, 403 cross-workspace, 400 bad request. + showToast(err.message || t('device.readopt.error'), 'error'); + btn.disabled = false; + } + }); + }); +} + +function setupActions(device) { // #104 Preview button document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device)); @@ -669,9 +765,11 @@ async function setupActions(device) { } }); - // Populate default content dropdown - try { - const content = await api.getContent(); + // Populate default content dropdown (async, non-blocking — same .then() pattern as the + // playlist picker below). setupActions is a SYNCHRONOUS function; awaiting here made the whole + // file fail to parse ("Unexpected reserved word") AND would have deferred every listener below + // (save, #150 re-adopt, delete) until this fetch resolved. .then() keeps them registering immediately. + api.getContent().then(content => { const defaultSelect = document.getElementById('deviceDefaultContent'); if (defaultSelect) { content.forEach(c => { @@ -681,7 +779,7 @@ async function setupActions(device) { defaultSelect.appendChild(opt); }); } - } catch {} + }).catch(() => {}); // Save settings (notes + orientation + default content) // Debug logging toggle: sends a transient set_debug command to the device and @@ -706,6 +804,10 @@ async function setupActions(device) { } }); + // #150 re-adopt: apply a previously-removed device's saved settings onto THIS device (the + // fallback for when the fingerprint changed and automatic restore couldn't fire). + document.getElementById('reAdoptBtn')?.addEventListener('click', () => showReAdoptModal(device)); + // Publish / Discard from device detail const devicePublishBtn = document.getElementById('devicePublishBtn'); if (devicePublishBtn && device.playlist_id) { diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index 60dfa49..06cf613 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -105,7 +105,7 @@ function openContentPicker({ multiple = false, title } = {}) { }); } -function showPreviewModal(html, widgetType) { +function showPreviewModal(sessionId, widgetType) { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; // #104: webpage widgets pointing at frame-denying sites (X-Frame-Options) can't be @@ -124,12 +124,7 @@ function showPreviewModal(html, widgetType) { ${webpageNote}
`; document.body.appendChild(overlay); - // srcdoc resolves relative URLs against about:srcdoc, so inject pointing to our origin - const baseTag = ``; - const withBase = /]*>/i.test(html) - ? html.replace(/]*)>/i, `${baseTag}`) - : html.replace(/]*)>/i, `${baseTag}`); - overlay.querySelector('#pvIframe').srcdoc = withBase; + overlay.querySelector('#pvIframe').src = '/api/widgets/preview-session/' + sessionId; const close = () => overlay.remove(); overlay.querySelector('#pvClose').onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); }; @@ -551,14 +546,14 @@ export async function render(container) { if (!type) return; const config = getConfigFromForm(type); try { - const res = await fetch('/api/widgets/preview', { + const res = await fetch('/api/widgets/preview-session', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ widget_type: type, config }), }); if (!res.ok) throw new Error(t('widget.toast.preview_failed')); - const html = await res.text(); - showPreviewModal(html, type); + const { id } = await res.json(); + showPreviewModal(id, type); } catch (err) { showToast(err.message, 'error'); } }; diff --git a/server/db/database.js b/server/db/database.js index 99c9954..e802b3b 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -88,6 +88,19 @@ const migrations = [ 'ALTER TABLE content ADD COLUMN team_id TEXT', // Device notes 'ALTER TABLE devices ADD COLUMN notes TEXT', + // v4 core pass — client identity capture (capture-don't-act; degrades to legacy/unknown for old + // pre-v4 clients that send no identity block). No logic is built on these yet. + 'ALTER TABLE devices ADD COLUMN client_type TEXT', + 'ALTER TABLE devices ADD COLUMN client_version TEXT', + 'ALTER TABLE devices ADD COLUMN platform TEXT', + 'ALTER TABLE devices ADD COLUMN contract_version TEXT', + // Exit-signal contract v1 — manner-of-death annotation on Offline (additive; NEVER alters offline + // detection). offline_reason: 'crashed'|'clean_exit' (client-sent via device:exit / beacon) or + // 'silent' (server-inferred when no signal arrived). Cleared on (re)online so it's always this + // session's. offline_detail: optional crash message / lifecycle-hook name. + 'ALTER TABLE devices ADD COLUMN offline_reason TEXT', + 'ALTER TABLE devices ADD COLUMN offline_reason_at INTEGER', + 'ALTER TABLE devices ADD COLUMN offline_detail TEXT', // Email settings on users "ALTER TABLE users ADD COLUMN email_alerts INTEGER DEFAULT 1", // Content folders @@ -254,6 +267,26 @@ const migrations = [ // settings_pin: 6-digit PIN for the in-app hidden settings menu, provisioned by // the server during pairing so each device gets a unique PIN (never a hardcoded default). "ALTER TABLE devices ADD COLUMN settings_pin TEXT", + // #150: fingerprint-keyed device settings that SURVIVE device-row deletion, so a + // delete + re-pair (MDM churn) restores orientation/name/playlist/etc for the SAME + // physical device instead of silently resetting to defaults. NO FK to devices -> it + // survives the delete cascade. workspace_id/device_name/last_seen/removed_at form the + // human-readable index the operator "re-adopt" flow browses when the fingerprint changed. + `CREATE TABLE IF NOT EXISTS device_settings ( + fingerprint TEXT PRIMARY KEY, + workspace_id TEXT, + device_name TEXT, + orientation TEXT, + timezone TEXT, + notes TEXT, + default_content_id TEXT, + layout_id TEXT, + playlist_id TEXT, + blocked INTEGER, + team_id TEXT, + last_seen INTEGER, + removed_at INTEGER + )`, ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/lib/device-settings.js b/server/lib/device-settings.js new file mode 100644 index 0000000..134fae8 --- /dev/null +++ b/server/lib/device-settings.js @@ -0,0 +1,124 @@ +'use strict'; +// #150 — fingerprint-keyed device settings that survive device-row deletion. +// +// Delete + re-pair (Bold's MDM churn) mints a BRAND-NEW device row whose INSERT omits every +// per-device setting, so orientation/name/playlist/etc silently reset to defaults. This module +// snapshots a device's settings (keyed by its durable hardware/canvas fingerprint) at DELETE +// time, and re-applies them on the next re-pair for the SAME fingerprint — automatically and +// silently. The same apply path also backs the operator "re-adopt" action for the case where +// the fingerprint changed (factory reset / new hardware), see routes/devices.js. +// +// The table has NO FK to devices, so device deletion can't cascade it away. On workspace/user/ +// org deletion the rows ARE purged (purgeWorkspaces) so settings can never bleed across tenants. +const { db } = require('../db/database'); + +const ORIENTATIONS = new Set(['landscape', 'portrait', 'landscape-flipped', 'portrait-flipped']); +const validOrientation = (o) => (ORIENTATIONS.has(o) ? o : 'landscape'); + +// The devices-row columns we preserve/restore (approved scope: orientation, name, timezone, +// notes, default_content_id, layout_id, playlist_id, blocked, team_id). sort_order is out of +// scope by decision; wall membership (video_wall_devices grid geometry) is a deferred follow-up. +const _selDevice = db.prepare( + `SELECT name, orientation, timezone, notes, default_content_id, layout_id, playlist_id, + blocked, team_id, workspace_id, last_heartbeat + FROM devices WHERE id = ?` +); +const _fpForDevice = db.prepare( + 'SELECT fingerprint FROM device_fingerprints WHERE device_id = ? ORDER BY last_seen DESC LIMIT 1' +); +const _upsert = db.prepare(` + INSERT INTO device_settings + (fingerprint, workspace_id, device_name, orientation, timezone, notes, default_content_id, + layout_id, playlist_id, blocked, team_id, last_seen, removed_at) + VALUES + (@fingerprint, @workspace_id, @device_name, @orientation, @timezone, @notes, @default_content_id, + @layout_id, @playlist_id, @blocked, @team_id, @last_seen, @removed_at) + ON CONFLICT(fingerprint) DO UPDATE SET + workspace_id=excluded.workspace_id, device_name=excluded.device_name, orientation=excluded.orientation, + timezone=excluded.timezone, notes=excluded.notes, default_content_id=excluded.default_content_id, + layout_id=excluded.layout_id, playlist_id=excluded.playlist_id, blocked=excluded.blocked, + team_id=excluded.team_id, last_seen=excluded.last_seen, removed_at=excluded.removed_at +`); + +// Snapshot a device's current settings keyed by its fingerprint, called BEFORE the row is +// deleted. No-op (returns null) if the device has no fingerprint link yet — a never-fully- +// provisioned device has no durable key and no user settings worth preserving. UPSERT keyed on +// fingerprint => repeated delete/re-pair cycles update one row, never duplicate. +function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) { + const d = _selDevice.get(deviceId); + if (!d) return null; + const fpRow = _fpForDevice.get(deviceId); + if (!fpRow || !fpRow.fingerprint) return null; + _upsert.run({ + fingerprint: fpRow.fingerprint, + workspace_id: d.workspace_id || null, + device_name: d.name || null, + orientation: validOrientation(d.orientation), + timezone: d.timezone || null, + notes: d.notes || null, + default_content_id: d.default_content_id || null, + layout_id: d.layout_id || null, + playlist_id: d.playlist_id || null, + blocked: d.blocked ? 1 : 0, + team_id: d.team_id || null, + last_seen: d.last_heartbeat || now, + removed_at: now, + }); + return fpRow.fingerprint; +} + +// Apply saved settings for `fingerprint` onto `deviceId`. Backs BOTH the automatic re-pair +// restore and the operator re-adopt. Orientation is enum-validated (invalid stored value -> +// landscape). FK settings (playlist/layout/default_content) are existence-guarded so a +// since-deleted target is skipped rather than written as a dangling id. Returns the applied +// snapshot row, or null if there was nothing to apply. +function applyToDevice(deviceId, fingerprint) { + const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint); + if (!s) return null; + const sets = [], vals = []; + const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); }; + + put('orientation', validOrientation(s.orientation)); + if (s.device_name != null) put('name', s.device_name); + if (s.timezone != null) put('timezone', s.timezone); + if (s.notes != null) put('notes', s.notes); + put('blocked', s.blocked ? 1 : 0); // security: a blocked device stays blocked across re-pair + if (s.team_id != null) put('team_id', s.team_id); + // FK-existence guards — only restore if the referenced row still exists. + if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id)) put('playlist_id', s.playlist_id); + if (s.layout_id && db.prepare('SELECT 1 FROM layouts WHERE id = ?').get(s.layout_id)) put('layout_id', s.layout_id); + if (s.default_content_id && db.prepare('SELECT 1 FROM content WHERE id = ?').get(s.default_content_id)) put('default_content_id', s.default_content_id); + // TODO #150 follow-up: wall membership (video_wall_devices grid geometry) is NOT restored — + // it lives in a separate CASCADE-deleted table with grid positions. Deferred; note in release. + + vals.push(deviceId); + db.prepare(`UPDATE devices SET ${sets.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...vals); + return s; +} + +// The "previously removed devices" browser — snapshots for the given workspace(s). +function listRemoved(workspaceIds) { + const ids = (Array.isArray(workspaceIds) ? workspaceIds : [workspaceIds]).filter(Boolean); + if (!ids.length) return []; + const ph = ids.map(() => '?').join(','); + return db.prepare( + `SELECT fingerprint, workspace_id, device_name, orientation, playlist_id, layout_id, + timezone, blocked, last_seen, removed_at + FROM device_settings WHERE workspace_id IN (${ph}) ORDER BY removed_at DESC` + ).all(...ids); +} + +function getByFingerprint(fingerprint) { + return db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint); +} + +// Purge snapshots for whole workspaces (workspace/user/org deletion). Runs on the caller's +// db handle (user-deletion runs inside a transaction). Prevents cross-tenant settings bleed. +function purgeWorkspaces(dbConn, workspaceIds) { + const ids = (workspaceIds || []).filter(Boolean); + if (!ids.length) return 0; + const ph = ids.map(() => '?').join(','); + return (dbConn || db).prepare(`DELETE FROM device_settings WHERE workspace_id IN (${ph})`).run(...ids).changes; +} + +module.exports = { snapshot, applyToDevice, listRemoved, getByFingerprint, purgeWorkspaces, validOrientation, ORIENTATIONS }; diff --git a/server/lib/liveness.js b/server/lib/liveness.js new file mode 100644 index 0000000..681c4cb --- /dev/null +++ b/server/lib/liveness.js @@ -0,0 +1,81 @@ +'use strict'; + +// v4 CORE-PASS liveness helpers — pure, VERSION-AGNOSTIC, mixed-fleet-safe. Dependency-free so they +// are unit-testable and the imperative shells (deviceSocket heartbeat/register handlers, the +// heartbeat offline sweep) stay thin. The server talks to a MIX simultaneously — v4 clients (have a +// watchdog, consume the ack, send an identity block), OLD pre-v4 clients (none of that), and +// genuinely-disconnected devices — and none of these may break the server or each other. + +// ── Uniform ack (PRIMARY + FIX 1: reconnect-window gap) ──────────────────────────────────────── +// Should THIS device:heartbeat be acked with device:heartbeat-ack? The ack keeps a v4 client's +// watchdog armed; it is emitted from the SHARED heartbeat handler (uniform by construction across +// APK / .wgt / /player) and is HARMLESS to old clients (they don't consume it). We ack a KNOWN +// device — identity-agnostic: +// - an already-authenticated socket (authedDeviceId set), OR +// - a heartbeat carrying a device_id that RESOLVES to a real device (a real device mid-reconnect, +// BEFORE this socket finished re-registering — the deferred ack-gap fix). +// We do NOT ack anonymous / never-authenticated sockets (no device_id, or an unknown id): those are +// covered by degrade-safe — an un-acked client's watchdog simply never arms, so there is no +// false-fire and no storm. +function ackableHeartbeat(authedDeviceId, heartbeatDeviceId, deviceExists) { + if (authedDeviceId) return true; // authenticated socket -> known + if (!heartbeatDeviceId) return false; // anonymous heartbeat -> not acked + return !!deviceExists(heartbeatDeviceId); // real device mid-reconnect -> ack (window fix) +} + +// ── Dashboard liveness (FIX 2: server-derived, VERSION-AGNOSTIC 3-state) ──────────────────────── +// Derived ONLY from signals EVERY client sends — socket presence, last-heartbeat age, reconnect +// frequency — never from v4-only signals. Correct for v4 clients, OLD clients (connected + +// heartbeating -> healthy), and disconnected clients (-> offline, a normal state, NOT an error). +// offline : no live socket. +// degraded : connected but reconnecting frequently (churn), OR connected but silent past the window. +// healthy : connected + a recent heartbeat + not churning. +const HEALTHY_HEARTBEAT_MS = 35000; // 2× the 15s client heartbeat + margin +const DEGRADED_RECONNECTS = 3; // >=3 (re)registers within the reconnect window => churn + +function deriveLiveness({ connected, lastHeartbeatAgeMs, recentReconnects } = {}, opts = {}) { + const hbMax = opts.healthyHeartbeatMs != null ? opts.healthyHeartbeatMs : HEALTHY_HEARTBEAT_MS; + const churn = opts.degradedReconnects != null ? opts.degradedReconnects : DEGRADED_RECONNECTS; + if (!connected) return 'offline'; + if ((recentReconnects || 0) >= churn) return 'degraded'; + if ((lastHeartbeatAgeMs || 0) > hbMax) return 'degraded'; + return 'healthy'; +} + +// ── Identity capture (FIX 3: capture-don't-act, DEGRADES on missing) ──────────────────────────── +// Capture the v4 identity block when present; when absent/partial (an OLD client), fill +// "legacy"/"unknown" — NEVER fail on a missing field. No logic is built on this yet. +function captureIdentity(data) { + const d = data || {}; + return { + client_type: d.client_type || 'legacy', + client_version: d.client_version || 'unknown', + platform: d.platform || 'unknown', + contract_version: d.contract_version || 'legacy', + }; +} + +// A1 change-detection: has the (already-captured) identity changed vs what's stored? A genuine +// reconnect with an unchanged identity (the common case) then does NO write. A never-stored device +// (current null / all-NULL columns) or a real change (e.g. new client_version after an OTA) writes. +function identityChanged(current, incoming) { + if (!current) return true; + return current.client_type !== incoming.client_type + || current.client_version !== incoming.client_version + || current.platform !== incoming.platform + || current.contract_version !== incoming.contract_version; +} + +// Exit-signal contract v1 — manner-of-death. A client may ONLY announce 'crashed' (its uncaught- +// exception handler fired) or 'clean_exit' (a confident lifecycle-end). 'silent' is server-inferred by +// ABSENCE and is NEVER accepted from a client. Honesty by construction: an unknown/uncertain value is +// rejected (-> null), so the device falls to server-inferred 'silent' rather than being coerced into a +// wrong category. detail is optional (crash message / lifecycle-hook name), sanitized + length-capped. +const CLIENT_EXIT_REASONS = ['crashed', 'clean_exit']; +function sanitizeExitReason(reason, detail) { + if (!CLIENT_EXIT_REASONS.includes(reason)) return null; + const d = (typeof detail === 'string' && detail.trim()) ? detail.trim().slice(0, 200) : null; + return { reason, detail: d }; +} + +module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS }; diff --git a/server/lib/ota-breaker.js b/server/lib/ota-breaker.js index 07245e0..c52bffd 100644 --- a/server/lib/ota-breaker.js +++ b/server/lib/ota-breaker.js @@ -59,6 +59,13 @@ function cmpParsed(a, b) { } function cmp(a, b) { const pa = parseVer(a), pb = parseVer(b); return (!pa || !pb) ? null : cmpParsed(pa, pb); } +// A '-patchN' suffix (e.g. 1.9.2-patch3) was the LEGACY release scheme — a shipped PRODUCTION patch, +// not a prerelease. Semver parses it into `pre`, but for OTA it must count as RELEASED so the +// superseded-prerelease guard below doesn't strand the old fleet: a 1.9.2-patchN device must still be +// offered a newer stable core (1.9.3). Genuine prereleases (-beta/-rc/-alpha) keep prerelease +// semantics. (Clean semver going forward emits no -patchN, so this only matters for the transition.) +function isReleased(p) { return p.pre === null || /^patch\d+$/i.test(p.pre); } + // decide(clientVersion, latestVersion, deviceId?, now?) -> // { update_available, reason, retry_after_seconds?, log? } function decide(clientVersion, latestVersion, deviceId = null, now = Date.now()) { @@ -69,7 +76,7 @@ function decide(clientVersion, latestVersion, deviceId = null, now = Date.now()) const full = cmpParsed(pc, pl); if (full === 0) return { update_available: false, reason: 'up-to-date' }; if (full > 0) return { update_available: false, reason: 'client-newer' }; // never offer a downgrade - if (pc.pre !== null && coreCmp(pc, pl) < 0) { // superseded old-core prerelease (e.g. 1.9.1-beta4) + if (!isReleased(pc) && coreCmp(pc, pl) < 0) { // GENUINE superseded old-core prerelease (e.g. 1.9.1-beta4) — a -patchN release is NOT one, so it still gets offered return { update_available: false, reason: 'superseded-prerelease', log: logOnce(clientVersion, `[ota] superseded prerelease '${clientVersion}' (older core than latest=${latestVersion}) — no offer`) }; } diff --git a/server/lib/user-deletion.js b/server/lib/user-deletion.js index 83c82bb..1c4e105 100644 --- a/server/lib/user-deletion.js +++ b/server/lib/user-deletion.js @@ -76,6 +76,11 @@ function purgeWorkspaces(db, wsIds, have) { } } for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds); + // #150: purge fingerprint-keyed device settings for these workspaces. device_settings has + // NO FK to devices (so it survives device deletion by design), which means it is NOT caught + // by this cascade either — purge it explicitly so saved settings can never bleed onto a + // different tenant if the same physical device (same fingerprint) later pairs elsewhere. + if (have.has('device_settings')) db.prepare(`DELETE FROM device_settings WHERE workspace_id IN (${wph})`).run(...wsIds); if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds); db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites } diff --git a/server/package-lock.json b/server/package-lock.json index 223cf15..cf906f1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "screentinker", - "version": "1.9.2-patch3", + "version": "1.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "screentinker", - "version": "1.9.2-patch3", + "version": "1.9.3", "dependencies": { "@azure/msal-node": "^5.2.1", "archiver": "^7.0.1", diff --git a/server/package.json b/server/package.json index 18f182e..4f6acad 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "screentinker", - "version": "1.9.2-patch3", + "version": "1.9.3", "description": "ScreenTinker - Digital Signage Management Server", "main": "server.js", "scripts": { diff --git a/server/player/index.html b/server/player/index.html index c5b8418..3944ae9 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -345,6 +345,44 @@ let config = getConfig(); let playlist = []; let currentIndex = -1; + + // ==================== Exit-signal contract v1 (best-effort last gasp) ==================== + // Announce manner-of-death via navigator.sendBeacon (reliable-on-unload — survives the socket + // teardown). crashed: real uncaught SCRIPT error / unhandled rejection. clean_exit: pagehide with + // persisted=false (a genuine unload — NOT a bfcache suspend, which the liveness watchdog handles, + // and NOT mere visibility-hidden). Honesty: only these two confident categories are ever sent; + // anything uncertain sends nothing -> the server infers 'silent'. Idempotent (first signal wins, + // so a crash is never relabelled clean_exit by the pagehide that follows it). + let __exitSent = false; + function sendExitBeacon(reason, detail) { + try { + if (__exitSent) return; + if (reason !== 'crashed' && reason !== 'clean_exit') return; + if (!config || !config.deviceId || !config.deviceToken) return; // unpaired -> nothing to attribute + __exitSent = true; + const url = (config.serverUrl || window.location.origin) + '/api/device/exit'; + const body = JSON.stringify({ device_id: config.deviceId, device_token: config.deviceToken, + reason, detail: (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined }); + const blob = new Blob([body], { type: 'application/json' }); // Content-Type so express.json parses it + if (navigator.sendBeacon && navigator.sendBeacon(url, blob)) return; + fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => {}); + } catch (e) { /* a dying page must never throw */ } + } + window.addEventListener('error', (ev) => { + // ONLY a real uncaught script error is a crash — a resource (img/script/link) load failure is NOT. + if (!ev) return; + const isResourceError = ev.target && ev.target !== window && (ev.target.src || ev.target.href); + if (isResourceError) return; + sendExitBeacon('crashed', (ev.error && ev.error.message) || ev.message || 'error'); + }); + window.addEventListener('unhandledrejection', (ev) => { + const r = ev && ev.reason; + sendExitBeacon('crashed', (r && (r.message || String(r))) || 'unhandledrejection'); + }); + window.addEventListener('pagehide', (ev) => { + if (ev && ev.persisted) return; // bfcache SUSPEND (may restore) — NOT a death; watchdog owns it + sendExitBeacon('clean_exit', 'pagehide'); + }); let isPlaying = false; let playerTimezone = null; // #74/#75: device-effective IANA tz for schedule eval let scheduleRetryTimer = null; // re-check when every item is filtered out @@ -745,6 +783,65 @@ container.appendChild(note); } + // ==================== v4 liveness watchdog ==================== + // Brings /player onto the LOCKED v4 contract, IDENTICAL on the wire to the APK and .wgt. + // Threshold 45s ± up to 10s jitter (canonical); arm ONLY after a device:heartbeat-ack + // (degrade-safe — an ack-less server never arms us); lastServerMessageAt refreshes on ANY + // inbound (the SILENCE check) while ARMING gates on the ack. Backoff (1s→30s ±20%) is on the + // socket.io Manager (io opts below). NO status/health poll — load is read from ack-silence. + // Browser-specific half-open triggers (visibility/resume/online) drive the SAME check + the + // #148 teardown-first reconnect — additional triggers, not a separate mechanism. + const PLAYER_VERSION = '1.1.0-web'; + const V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000; + function v4ThresholdMs(rand) { return V4_THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * V4_THRESHOLD_JITTER_MS); } + // Wall-clock (Date.now) so silence COUNTS sleep/background time — the browser half-open causes + // (a setInterval tick alone would be throttled/frozen in a hidden tab and undercount). + let lastServerMessageAt = 0; + let livenessConfirmed = false; + let livenessWindowMs = V4_THRESHOLD_BASE_MS; + let watchdogTimer = null; + function markAlive() { lastServerMessageAt = Date.now(); } // ANY inbound refreshes silence (does NOT arm) + // Pure decision (matches the APK/.wgt watchdogShouldReconnect): reconnect only a connected + + // registered socket whose liveness we've ARMED that has gone silent past the jittered window. + function watchdogShouldReconnect(hasSocket, connected, armed, silentMs, windowMs) { + return !!(hasSocket && connected && armed && silentMs > windowMs); + } + function checkLiveness() { + // THROTTLE-AWARE. (1) Silence is computed by TIMESTAMP (now - lastServerMessageAt), never by + // timer-fire-count, so a background-THROTTLED/late timer still measures the ACTUAL elapsed + // silence — it won't miss a real half-open for firing late, nor false-fire for firing late. + // (2) While the tab is HIDDEN, timers are throttled and the gap is EXPECTED — do NOT act on it + // (never reconnect a backgrounded tab). The hidden->visible transition resets the grace via + // verifyLivenessSoon(), so we don't false-fire on resume either. + if (document.visibilityState !== 'visible') return; + const silentMs = lastServerMessageAt ? (Date.now() - lastServerMessageAt) : 0; + if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), livenessConfirmed, silentMs, livenessWindowMs)) { + console.log('[v4] half-open (silent ' + silentMs + 'ms > ' + livenessWindowMs + ') — teardown+reconnect'); + connect(config.serverUrl); // #148 teardown-before-reopen: connect() disconnects the old socket first + } + } + // Fresh liveness check for a resume / bfcache-restore / network-change: silence accumulated + // while hidden or throttled is NOT trustworthy, so NEVER tear down a possibly-live socket on it. + // Reset the grace so the socket gets a fresh window to prove itself — a live socket's engine + // ping/ack refreshes silence within the window (no reconnect); a genuinely dead one stays silent + // past the window and the (now-visible) watchdog reconnects it. #148 teardown-first via connect(). + function verifyLivenessSoon() { + lastServerMessageAt = Date.now(); // fresh grace — don't false-fire on stale hidden-silence + if (!socket) connect(config.serverUrl); // never connected / torn down -> establish (teardown-first) + // socket connected -> grace reset above; the watchdog verifies over the next window. + // socket present-but-disconnected -> socket.io's own reconnection already owns it. + } + function startWatchdog() { stopWatchdog(); watchdogTimer = setInterval(checkLiveness, 10000); } + function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } } + function browserPlatform() { + try { + const m = navigator.userAgent.match(/(Edg|OPR|Chrome|Firefox|Version)\/(\d+)/); + if (m) { const name = { Edg: 'Edge', OPR: 'Opera', Version: 'Safari' }[m[1]] || m[1]; return name + ' ' + m[2]; } + return 'Browser'; + } catch (e) { return 'Browser'; } + } + if (typeof window !== 'undefined') { window.__v4ThresholdMs = v4ThresholdMs; window.__v4WatchdogShouldReconnect = watchdogShouldReconnect; } + // ==================== Socket Connection ==================== function connect(serverUrl) { if (socket) { socket.disconnect(); socket = null; } @@ -752,8 +849,9 @@ socket = io(serverUrl + '/device', { reconnection: true, reconnectionAttempts: Infinity, - reconnectionDelay: 2000, - reconnectionDelayMax: 10000, + reconnectionDelay: 1000, // v4 canonical: 1s start (was 2s) + reconnectionDelayMax: 30000, // v4 canonical: 30s cap, within the ~30-60s band (was 10s) + randomizationFactor: 0.2, // v4 canonical: ±20% jitter (was the socket.io 0.5 default) timeout: 20000, // Prefer WebSocket but allow polling fallback. Socket.IO default is // polling-first with an upgrade dance that's fragile on TV WebKits @@ -765,6 +863,17 @@ transports: ['websocket', 'polling'], }); + // v4 liveness: a fresh socket is assumed alive; DIS-arm until a heartbeat-ack re-arms; pick a + // fresh jittered window for this connection. markAlive on ANY inbound (app events via onAny + + // the engine ping) refreshes the SILENCE timer only — it does NOT arm. + lastServerMessageAt = Date.now(); + livenessConfirmed = false; + livenessWindowMs = v4ThresholdMs(Math.random()); + socket.onAny(markAlive); + socket.io.on('ping', markAlive); + // v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack. + socket.on('device:heartbeat-ack', () => { livenessConfirmed = true; }); + socket.on('connect', () => { console.log('Connected'); register(); @@ -773,6 +882,7 @@ socket.on('disconnect', () => { console.log('Disconnected'); stopHeartbeat(); + stopWatchdog(); // socket.io owns the reconnect once it KNOWS it's down; watchdog is for half-open only }); socket.on('connect_error', (err) => { @@ -797,6 +907,7 @@ } startHeartbeat(); + startWatchdog(); // v4: arm-gated half-open watchdog (no-op until a heartbeat-ack arms it) startPlaylistRefresh(); startVersionCheck(); }); @@ -1082,6 +1193,12 @@ screen_width: screen.width, screen_height: screen.height, }; + // v4 client identity block — additive, canonical snake_case (same shape as APK/.wgt so the + // server consumes one thing). Backward-compatible: an old server ignores unknown fields. + data.client_type = 'player'; + data.client_version = PLAYER_VERSION; + data.platform = browserPlatform(); + data.contract_version = 'v4'; // Browser fingerprint (survives localStorage clear) data.fingerprint = generateBrowserFingerprint(); console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`); @@ -2193,7 +2310,18 @@ } catch {} } requestWakeLock(); - document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') requestWakeLock(); }); + // v4 browser half-open triggers: tab foreground / bfcache resume / network change all drive the + // SAME liveness check + #148 teardown-first reconnect (checkLiveness -> connect() only if the + // socket is armed+connected+silent-past-window). checkLiveness no-ops on a healthy socket, so a + // visibility change does NOT spawn a duplicate socket (the classic browser bug). + document.addEventListener('visibilitychange', () => { + // On becoming visible after a (possibly throttled/frozen) background stint, do the fresh + // liveness check — reset the grace and reconnect ONLY if genuinely dead; never spuriously + // tear down a live socket on the hidden->visible gap. + if (document.visibilityState === 'visible') { requestWakeLock(); verifyLivenessSoon(); } + }); + window.addEventListener('pageshow', verifyLivenessSoon); // sleep/resume via bfcache restore + window.addEventListener('online', verifyLivenessSoon); // network switch (wifi<->cellular) // Register service worker for offline content caching if ('serviceWorker' in navigator) { diff --git a/server/routes/devices.js b/server/routes/devices.js index 8945a15..9a4d393 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -7,6 +7,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa const { accessContext } = require('../lib/tenancy'); const { stripDeviceSecrets } = require('../lib/device-sanitize'); const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate'); +const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings preservation // List devices in the caller's current workspace. // Phase 2.2a: filter by workspace_id instead of user_id. The caller's current @@ -85,6 +86,14 @@ router.get('/unassigned', (req, res) => { res.json(devices); }); +// #150: "previously removed devices" — fingerprint-keyed settings snapshots for the caller's +// current workspace, for the operator re-adopt flow (changed-fingerprint case). MUST be +// declared before GET '/:id' or Express matches 'removed' as an :id. Read-scoped to workspace. +router.get('/removed', (req, res) => { + if (!req.workspaceId) return res.json([]); + res.json(deviceSettings.listRemoved(req.workspaceId)); +}); + // Get single device with telemetry history router.get('/:id', (req, res) => { const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id); @@ -202,6 +211,11 @@ router.put('/:id', (req, res) => { if (!device) return; const { name, notes, timezone, orientation, default_content_id, layout_id } = req.body; + // #150: validate orientation against the known enum (previously accepted any string, which + // let a bad value reach the player -> unknown rotation falls back to landscape silently). + if (orientation !== undefined && !deviceSettings.ORIENTATIONS.has(orientation)) { + return res.status(400).json({ error: `Invalid orientation. Allowed: ${[...deviceSettings.ORIENTATIONS].join(', ')}` }); + } // Whitelist allowed fields to prevent SQL injection via field names const ALLOWED_FIELDS = ['name', 'notes', 'timezone', 'orientation', 'default_content_id']; const updates = []; @@ -253,11 +267,36 @@ router.post('/:id/unblock', (req, res) => { res.json({ success: true, id: req.params.id, blocked: false }); }); +// #150: re-adopt — apply a removed device's saved settings onto device :id. For the case the +// fingerprint did NOT auto-match (factory reset / new hardware), so the automatic re-pair +// restore couldn't fire. Auth: caller can write device :id (checkDeviceOwnership) AND the +// snapshot belongs to the SAME workspace as the device (no cross-tenant apply). +router.post('/:id/re-adopt', (req, res) => { + const device = checkDeviceOwnership(req, res); + if (!device) return; + const { fingerprint } = req.body || {}; + if (!fingerprint) return res.status(400).json({ error: 'fingerprint required' }); + const snap = deviceSettings.getByFingerprint(fingerprint); + if (!snap) return res.status(404).json({ error: 'No saved settings for that fingerprint' }); + if (snap.workspace_id !== device.workspace_id) { + return res.status(403).json({ error: 'Saved settings belong to a different workspace' }); + } + deviceSettings.applyToDevice(req.params.id, fingerprint); + const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id); + console.log(`[#150] re-adopted settings (fp ${fingerprint.slice(0, 8)}…) onto device ${req.params.id} by user ${req.user.id}`); + res.json(stripDeviceSecrets(updated)); +}); + // Delete device router.delete('/:id', (req, res) => { const device = checkDeviceOwnership(req, res); if (!device) return; + // #150: snapshot this device's settings (keyed by its fingerprint) BEFORE the row dies, + // so a re-pair of the SAME physical device restores orientation/name/playlist/etc instead + // of silently resetting to defaults. No-op if the device has no fingerprint link yet. + try { deviceSettings.snapshot(req.params.id); } catch (e) { console.warn(`[#150] settings snapshot failed for ${req.params.id}: ${e.message}`); } + // Clean up related data (playlist is NOT deleted — may be shared with other devices) db.prepare('DELETE FROM schedules WHERE device_id = ?').run(req.params.id); db.prepare('DELETE FROM screenshots WHERE device_id = ?').run(req.params.id); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 76e057c..ce9d47b 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -214,6 +214,7 @@ router.get('/:id', requirePlaylistRead, (req, res) => { ORDER BY pi.sort_order ASC `).all(req.params.id); const displayCount = db.prepare('SELECT COUNT(*) as count FROM devices WHERE playlist_id = ?').get(req.params.id).count; + for (const it of items) it.schedules = schedulesForItem(it.id); // #156: editor read-path needs the blocks (mirror :351) res.json({ ...req.playlist, items, item_count: items.length, display_count: displayCount }); }); diff --git a/server/routes/widgets.js b/server/routes/widgets.js index a854b03..7212b15 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -212,6 +212,42 @@ router.post('/preview', (req, res) => { res.send(html); }); +// Preview sessions — ephemeral store so the preview iframe loads via src (not srcdoc) +// and bypasses the dashboard CSP that would block the widget's inline scripts. +const previewStore = new Map(); +const PREVIEW_TTL = 5 * 60 * 1000; +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of previewStore) { + if (now - entry.created > PREVIEW_TTL) previewStore.delete(key); + } +}, 60 * 1000).unref(); + +router.post('/preview-session', (req, res) => { + const { widget_type, config } = req.body || {}; + if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' }); + if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' }); + const id = uuidv4(); + const html = renderWidgetHtml(widget_type, config || {}); + previewStore.set(id, { html, widget_type, created: Date.now() }); + res.json({ id, url: `/api/widgets/preview-session/${id}` }); +}); + +router.get('/preview-session/:id', (req, res) => { + const entry = previewStore.get(req.params.id); + if (!entry) return res.status(410).send('Preview expired'); + if (Date.now() - entry.created > PREVIEW_TTL) { + previewStore.delete(req.params.id); + return res.status(410).send('Preview expired'); + } + let html = entry.html; + if (req.workspaceId) html = inlineUserContent(html, req.workspaceId); + res.removeHeader('X-Frame-Options'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'text/html'); + res.send(html); +}); + function renderClock(c) { return `