Finished build for the TVs and steadier lists

Port of release 0.108.61.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 16:19:38 +00:00
co-authored by Claude Opus 5.5
parent d03cefe6f8
commit 7fa1384d68
6 changed files with 76 additions and 3 deletions
+8
View File
@@ -13,6 +13,14 @@ android {
versionName = "0.111.0-advent-premieres"
}
buildFeatures { compose = true }
// The finished build for the TVs (Paul, 6 November 2026: the test build stuttered on the box). Not debuggable, so
// Android compiles it fully; signed with the same key as the test builds, so it installs over them keeping everything.
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
}
}
// Android logging and the like do nothing in unit tests, so whole flows (a channel read from a fake server) can run.
testOptions { unitTests.isReturnDefaultValues = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
+4
View File
@@ -8,6 +8,10 @@
<activity android:name=".MainActivity" android:exported="true" android:screenOrientation="landscape">
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LEANBACK_LAUNCHER" /></intent-filter>
</activity>
<!-- The install check (InstallSnapshot.kt): only adb can send it, as it needs the DUMP permission apps can't hold. -->
<receiver android:name=".InstallSnapshotReceiver" android:exported="true" android:permission="android.permission.DUMP">
<intent-filter><action android:name="tv.orbit.classic.INSTALL_SNAPSHOT" /></intent-filter>
</receiver>
<!-- The hand-run playback test, opened only by MainActivity's orbit_play_test extra. -->
<activity android:name=".PlaybackTestActivity" android:exported="false" />
</application>
@@ -80,7 +80,7 @@ internal fun HomeScreen(channels: List<SampleChannel>, modifier: Modifier = Modi
val glide = rememberGlide()
CompositionLocalProvider(LocalGlide provides glide) { Column(Modifier.width(400.dp).glideArea(glide, Yellow, 6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
homeCategories(channels.map { it.number }).forEachIndexed { index, category ->
HomeCard(Modifier.fillMaxWidth().height(31.dp).riseIn(index).focusProperties { if (index == 0 && nav != null) up = nav }, if (index == 0) firstFocus else null, onClick = { onCategory(category) }) { focused ->
HomeCard(Modifier.fillMaxWidth().height(31.dp).focusProperties { if (index == 0 && nav != null) up = nav }, if (index == 0) firstFocus else null, onClick = { onCategory(category) }) { focused ->
Row(Modifier.fillMaxSize().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Image(painterResource(category.icon), null, Modifier.size(18.dp), colorFilter = ColorFilter.tint(if (focused) OnAccent else Ink))
Text(category.label.uppercase(java.util.Locale.UK), color = if (focused) OnAccent else Ink, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = .5.sp, maxLines = 1)
@@ -0,0 +1,61 @@
package tv.orbit.classic
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.io.File
import java.security.MessageDigest
/**
* The install check for a finished (non-debuggable) build (Paul, 6 November 2026), where adb's run-as can no longer read
* Orbit's files. Sent only from adb (the receiver needs the DUMP permission, which apps can't hold):
*
* - `snapshot`: copies the schedules and settings into Orbit's own folder on shared storage, with the library's and
* sign-in's checksums, for adb to pull and compare. Nothing is read out of them here.
* - `rollback <version>`: copies the schedules aside as schedule-rollback-<version>, as every install does.
* - `clear`: removes the copy once it's been pulled.
*
* It only ever copies; it never changes the schedules, the library or the sign-in.
*/
class InstallSnapshotReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val result = runCatching { InstallSnapshot.handle(context, intent.getStringExtra("do").orEmpty(), intent.getStringExtra("version")) }
resultData = result.fold({ it }, { "failed" })
android.util.Log.i("OrbitInstall", "${intent.getStringExtra("do")}: $resultData")
}
}
internal object InstallSnapshot {
private val versionShape = Regex("""\d+\.\d+\.\d+""")
fun handle(context: Context, action: String, version: String?): String {
val data = context.filesDir.parentFile ?: return "no data"
val out = File(context.getExternalFilesDir(null), "install-snapshot")
return when (action) {
"snapshot" -> {
out.deleteRecursively()
val schedule = File(data, "no_backup/schedule")
schedule.copyRecursively(File(out, "no_backup/schedule"), overwrite = true)
File(data, "shared_prefs/orbit_settings.xml").takeIf { it.exists() }?.copyTo(File(out, "shared_prefs/orbit_settings.xml"), overwrite = true)
File(out, "hashes.txt").writeText(listOf("no_backup/catalogue.db", "no_backup/orbit-connection.v1")
.joinToString("\n") { "${sha256(File(data, it))} $it" } + "\n")
"copied ${schedule.walk().count { it.isFile }}"
}
"rollback" -> {
if (version == null || !versionShape.matches(version)) return "bad version"
val schedule = File(data, "no_backup/schedule")
val aside = File(data, "no_backup/schedule-rollback-$version")
aside.deleteRecursively(); schedule.copyRecursively(aside, overwrite = true)
"aside ${aside.walk().count { it.isFile }}"
}
"clear" -> { out.deleteRecursively(); "cleared" }
else -> "unknown"
}
}
private fun sha256(file: File): String = if (!file.exists()) "missing" else file.inputStream().use { input ->
val digest = MessageDigest.getInstance("SHA-256"); val buffer = ByteArray(64 * 1024)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
digest.digest().joinToString("") { "%02x".format(it) }
}
}
@@ -493,7 +493,7 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
// Tall enough for the name and its line in full (Paul, 5 November 2026: the line was clipped at 48).
Row(Modifier.fillMaxWidth().height(56.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
pair.forEachIndexed { i, game ->
GameCard(game, Modifier.weight(1f).fillMaxHeight().riseIn(row * 2 + i), if (row == 0 && i == 0) first else null, if (row == 0) nav else null, { chosen = game }, { play(game) })
GameCard(game, Modifier.weight(1f).fillMaxHeight(), if (row == 0 && i == 0) first else null, if (row == 0) nav else null, { chosen = game }, { play(game) })
}
if (pair.size == 1) Spacer(Modifier.weight(1f))
}
@@ -174,7 +174,7 @@ internal fun WhosWatching(onClose: () -> Unit) {
// Kids first, so a child's OK goes straight to their own.
Profiles.all.sortedBy { if (it.kind == ProfileKind.Kids) 0 else 1 }.forEachIndexed { i, p ->
var focused by remember { mutableStateOf(false) }
Column(Modifier.riseIn(i).then(if (i == 0) Modifier.focusRequester(first) else Modifier).onFocusChanged { focused = it.isFocused }
Column(Modifier.then(if (i == 0) Modifier.focusRequester(first) else Modifier).onFocusChanged { focused = it.isFocused }
// No grey focus box behind the picture (Paul, 6 November 2026): the gold ring and name say it's chosen.
.clickable(remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, indication = null) { choose(p) },
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {