From d978a5d2a604c20be1ccb4e939f99d8f44c241d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:20:41 -0500 Subject: [PATCH] Make per-item scheduling work on Android 7 instead of blanking the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScheduleEval uses java.time — Instant, LocalDate, ZoneId — which is API 26. minSdk is 24, and core library desugaring was never enabled, so on Android 7.0/7.1 the first evaluation threw NoClassDefFoundError. Those API levels are still common on cheap signage sticks and older TV boxes. The damage was much worse than a failed check, because NoClassDefFoundError is an Error, not an Exception. The evaluator's deliberate fail-open guard — written so that "a blank screen is worse than an over-running promo" — did not catch it. The Error propagated out of scheduleAllows, through firstActiveIndex and updatePlaylist, past another catch(Exception), and was only swallowed at the service boundary. Because updatePlaylist aborted before the download block, no content was fetched either; and on a cold start from cache the same Error reached a handler that clears the playlist cache. So the moment anyone used dayparting or expiry, those panels sat on "waiting for content" with nothing downloaded and nothing cached, and a reboot did not help. The stated contract was inverted on exactly the hardware it was meant to protect. Two changes. Desugaring is the real fix: java.time now exists on API 24/25, so the code runs as written. The guard is widened to Throwable as well, so this class of failure can never again slip past a catch that was written to be total — that is belt and braces, not the fix. Release build assembles cleanly with desugaring on; 134 Android JVM tests green. Still to confirm on a real API 24/25 image before release — the unit tests run on the JVM, where java.time always exists, which is precisely why this was invisible to them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- android/app/build.gradle.kts | 9 +++++++++ .../java/com/remotedisplay/player/player/ScheduleEval.kt | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 35dceab..c03445c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -43,6 +43,14 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + // ScheduleEval uses java.time (Instant/LocalDate/ZoneId), which is API 26 — but minSdk is + // 24. Without desugaring, per-item dayparting/expiry threw NoClassDefFoundError on Android + // 7.0/7.1, which are still common on cheap signage sticks and older TV boxes. Because that + // is an Error and not an Exception, the evaluator's deliberate fail-open guard did not + // catch it: the playlist update aborted before content downloaded, and the cold-start path + // then cleared the playlist cache — so the screen sat on "waiting for content" and a reboot + // did not help. + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -63,6 +71,7 @@ android { } dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") // AndroidX implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") diff --git a/android/app/src/main/java/com/remotedisplay/player/player/ScheduleEval.kt b/android/app/src/main/java/com/remotedisplay/player/player/ScheduleEval.kt index e9cdc4d..56261ac 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/ScheduleEval.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/ScheduleEval.kt @@ -43,7 +43,12 @@ object ScheduleEval { val nowMin = zdt.hour * 60 + zdt.minute val date = zdt.toLocalDate() blocks.any { blockMatches(it, dow, nowMin, date) } - } catch (e: Exception) { + } catch (e: Throwable) { + // Throwable, not Exception. A missing java.time on an old API level surfaces as + // NoClassDefFoundError — an Error — which sailed straight through a catch(Exception) + // and turned this "fail open, a blank screen is worse than an over-running promo" + // contract into its exact opposite: nothing played at all. Desugaring (see + // build.gradle.kts) is the real fix; this makes the guard mean what it says. true // fail open } }