diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 16d03212..8e5f6b03 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c8186022..f24e7509 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ + + + + diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index 7febf931..c704062e 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -80,7 +80,7 @@ internal fun HomeScreen(channels: List, 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) diff --git a/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt b/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt new file mode 100644 index 00000000..f18af259 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt @@ -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 `: copies the schedules aside as schedule-rollback-, 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) } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 499e6c26..894abf23 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -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)) } diff --git a/android/app/src/main/java/tv/orbit/classic/Profiles.kt b/android/app/src/main/java/tv/orbit/classic/Profiles.kt index a7bd42a8..b0afcafc 100644 --- a/android/app/src/main/java/tv/orbit/classic/Profiles.kt +++ b/android/app/src/main/java/tv/orbit/classic/Profiles.kt @@ -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)) {