diff --git a/VERIFICATION-0.57.0.md b/VERIFICATION-0.57.0.md new file mode 100644 index 0000000..4b858b8 --- /dev/null +++ b/VERIFICATION-0.57.0.md @@ -0,0 +1,16 @@ +# Orbit TV 0.55.0 to 0.57.0 verification + +A cleaner guide, and real programme artwork behind the channel logo. + +## Changed +- 0.55: finished programmes aren't drawn, what's on now fills to the start of the row, no empty blocks, no time line, an arrow on shows running past the edge. Paul confirmed this looks right. +- 0.56: drawn scenes for ToonZ, Evidence, Orbit X and Rewind. Paul found them off brand, and they were never installed. They're no longer shown. +- 0.57: while tuning, and in the guide's picture until the first frame, the app shows the programme's real artwork from Emby, dimmed, with the channel logo on top. It tries the show's backdrop, then the programme's own. With none, it shows plain navy with the logo. Pictures are cached on the box, at most 150 files, and a show's picture is fetched once. Requests use the same fixed provider address and no redirects. + +## Checked +- assembleDebug, testDebugUnitTest and lintDebug pass; 239 tests, 0 failures. Tests cover the order pictures are tried, failures, bad IDs, one fetch per show and the cache limit. +- APK signature verified with apksigner. + +## Not checked +- Not yet run on the box. Whether Paul's provider has backdrops for his shows, how they look dimmed, and how quickly they appear can only be seen there. +- Old channel backgrounds remain in the app but are mostly unused. Removing them is a later tidy. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5d816d9..ef46e24 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 57 - versionName = "0.56.0-channel-art" + versionCode = 58 + versionName = "0.57.0-real-artwork" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 231cee9..4e1b0d3 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -231,6 +231,7 @@ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List */ internal class ScheduledChannel(val loop: SampleChannel, plan: ChannelPlan) : SampleChannel(loop.lineup, loop.name, loop.number) { @Volatile var plan: ChannelPlan = plan + fun seriesIdAt(startMs: Long) = plan.entries.firstOrNull { it.startMs == startMs }?.seriesId override fun at(timeMs: Long): SampleBroadcast { val entries = plan.entries var low = 0 diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt b/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt index 16d4b88..9c88030 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -14,11 +13,11 @@ import androidx.tv.material3.Text /** Presentation only: no timers, media requests or schedule changes. */ @Composable -internal fun ChannelTuningBackdrop(number: Int, modifier: Modifier = Modifier) { +internal fun ChannelTuningBackdrop(number: Int, modifier: Modifier = Modifier, artwork: @Composable () -> Unit = {}) { val branding = ChannelBrands.forNumber(number) Box(modifier.fillMaxSize().background(Navy)) { + artwork() if (branding != null) { - Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) Image(painterResource(branding.logo), null, Modifier.align(Alignment.TopStart).padding(start = 40.dp, top = 32.dp).width(200.dp).height(62.dp)) } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 4994b5b..0c96b63 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -19,6 +19,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI companion object { const val SERVER = "https://bk2.appboxmanager.xyz" const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024 + const val MAX_IMAGE_BYTES = 6 * 1024 * 1024 internal fun readBounded(stream: InputStream): String { val output = ByteArrayOutputStream() val buffer = ByteArray(8192) @@ -31,6 +32,30 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI return output.toString(Charsets.UTF_8.name()) } } + /** One picture from the provider, or nothing. Same fixed origin and no redirects, and failures are never shown or logged. */ + fun fetchImage(path: String, token: String): ByteArray? { + require(path.startsWith("/") && !path.startsWith("//")) + val connection = URL("$SERVER/emby$path").openConnection() as HttpsURLConnection + try { + connection.instanceFollowRedirects = false + connection.connectTimeout = 10_000 + connection.readTimeout = 15_000 + connection.setRequestProperty("X-Emby-Token", token) + if (connection.responseCode != 200 || connection.contentType?.startsWith("image/") != true || connection.contentLengthLong > MAX_IMAGE_BYTES) return null + val output = ByteArrayOutputStream() + connection.inputStream.use { stream -> + val buffer = ByteArray(8192) + while (true) { + val count = stream.read(buffer) + if (count == -1) break + if (output.size() + count > MAX_IMAGE_BYTES) return null + output.write(buffer, 0, count) + } + } + return output.toByteArray() + } catch (_: java.io.IOException) { return null } + finally { connection.disconnect() } + } override fun execute(request: EmbyRequest): EmbyReply { // Paths come from the protocol below, never from a provider response or a text field. require(request.path.startsWith("/") && !request.path.startsWith("//")) @@ -41,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.56.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.57.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index 17462c8..6fef274 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -238,7 +238,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl } }, update = { it.player = player }, onRelease = { it.player = null }) if (scheduled != null && !ready && failure == null && foreground) - ChannelTuningBackdrop(scheduled.channel.number) + ChannelTuningBackdrop(scheduled.channel.number) { ProgrammeArtwork(session, scheduled.channel, scheduled.programme) } if (scheduled != null && ready && tuningNumberVisible && failure == null) ClassicTuningNumber(scheduled.channel.number, Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp)) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 105ed0a..9e41b5e 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -226,6 +226,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v } fun suggestFor(rules: List, alreadyInPool: Set, rejected: Set, checkActive: () -> Unit = {}) = findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive) + fun fetchArtwork(path: String): ByteArray? = (transport as? HttpsEmbyTransport)?.fetchImage(path, currentToken()) fun rememberScheduledItems(items: List) { channelEpisodes = channelEpisodes + items.associateBy { it.id } } fun checkMetadataCoverage(checkActive: () -> Unit = {}): List = diff --git a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt index 3ef1b6b..a595a7c 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -3,10 +3,12 @@ package tv.orbit.classic import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.viewinterop.AndroidView @@ -79,7 +81,10 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true }) } val branding = ChannelBrands.forNumber(channel.number) - if (!ready && branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) + if (!ready) Box(Modifier.matchParentSize().background(Panel)) { + ProgrammeArtwork(session, channel, current) + if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.align(androidx.compose.ui.Alignment.Center).width(150.dp).height(47.dp)) + } } } diff --git a/android/app/src/main/java/tv/orbit/classic/ProgrammeArtwork.kt b/android/app/src/main/java/tv/orbit/classic/ProgrammeArtwork.kt new file mode 100644 index 0000000..e0dcc5d --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ProgrammeArtwork.kt @@ -0,0 +1,78 @@ +package tv.orbit.classic + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +private const val ARTWORK_KEEP_FILES = 150 + +/** Where a programme's picture may be: its show's wide artwork first, as that suits a full screen, then its own. */ +internal fun artworkPaths(itemId: String, seriesId: String?): List = + (listOfNotNull(seriesId, itemId).distinct().filter { EmbyClient.validId(it) }.map { "/Items/$it/Images/Backdrop?maxWidth=1280&quality=80" } + + listOf(itemId).filter { EmbyClient.validId(it) }.map { "/Items/$it/Images/Primary?maxWidth=1280&quality=80" }) + +/** Keeps artwork on the box between visits, within a fixed number of files, so a show's picture is only fetched once. */ +internal class ArtworkCache(private val directory: File) { + private fun file(key: String) = File(directory, "$key.img") + fun get(key: String): ByteArray? = file(key).takeIf { it.isFile }?.let { found -> + found.setLastModified(System.currentTimeMillis()) + runCatching { found.readBytes() }.getOrNull() + } + fun put(key: String, bytes: ByteArray) { + directory.mkdirs() + val temporary = File(directory, "$key.tmp") + temporary.writeBytes(bytes) + temporary.renameTo(file(key)) + trim() + } + private fun trim() { + directory.listFiles()?.sortedByDescending { it.lastModified() }?.drop(ARTWORK_KEEP_FILES)?.forEach { it.delete() } + } +} + +/** The first picture available, from the box if it's there. Any failure just means no picture. */ +internal fun loadArtwork(cache: ArtworkCache, itemId: String, seriesId: String?, fetch: (String) -> ByteArray?): ByteArray? { + if (!EmbyClient.validId(itemId)) return null + val key = (seriesId ?: itemId).takeIf { EmbyClient.validId(it) } ?: itemId + cache.get(key)?.let { return it } + for (path in artworkPaths(itemId, seriesId)) { + val bytes = runCatching { fetch(path) }.getOrNull() ?: continue + if (bytes.isEmpty()) continue + runCatching { cache.put(key, bytes) } + return bytes + } + return null +} + +/** The programme's real picture, dimmed so the channel logo and number read on top. With no picture, the plain background shows through. */ +@Composable +internal fun ProgrammeArtwork(session: EmbySampleSession, channel: SampleChannel, programme: SampleBroadcast, modifier: Modifier = Modifier) { + val context = LocalContext.current + val itemId = programme.media.item.id + val seriesId = remember(channel, programme.startMs) { (channel as? ScheduledChannel)?.seriesIdAt(programme.startMs) } + val picture by produceState(null, itemId, seriesId) { + value = withContext(Dispatchers.IO) { + val cache = ArtworkCache(File(context.cacheDir, "artwork")) + runCatching { + loadArtwork(cache, itemId, seriesId, session::fetchArtwork)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() } + }.getOrNull() + } + } + picture?.let { + Box(modifier.fillMaxSize()) { + Image(it, null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) + Box(Modifier.matchParentSize().background(Color.Black.copy(alpha = .45f))) + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 4b09088..85e4cdc 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -143,7 +143,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC } } else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { - if (problem == null) ChannelTuningBackdrop(channel.number) + if (problem == null) ChannelTuningBackdrop(channel.number) { ProgrammeArtwork(session, channel, current) } else Box(Modifier.fillMaxSize().background(Navy)) { Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).padding(32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(problem.orEmpty(), color = Color.White, fontSize = 20.sp) diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 920fe11..5ed490e 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.56.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.57.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/android/app/src/test/java/tv/orbit/classic/ProgrammeArtworkTest.kt b/android/app/src/test/java/tv/orbit/classic/ProgrammeArtworkTest.kt new file mode 100644 index 0000000..0f04ceb --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/ProgrammeArtworkTest.kt @@ -0,0 +1,60 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ProgrammeArtworkTest { + @get:Rule val folder = TemporaryFolder() + private fun cache() = ArtworkCache(folder.newFolder()) + private val png = byteArrayOf(1, 2, 3) + + @Test fun theShowsWideArtworkIsTriedFirst() { + assertEquals(listOf("/Items/show/Images/Backdrop?maxWidth=1280&quality=80", "/Items/ep/Images/Backdrop?maxWidth=1280&quality=80", "/Items/ep/Images/Primary?maxWidth=1280&quality=80"), artworkPaths("ep", "show")) + } + + @Test fun aFilmOnlyAsksForItsOwnArtwork() { + assertEquals(2, artworkPaths("film", "film").size) + assertEquals(2, artworkPaths("film", null).size) + } + + @Test fun badIdsAreNeverRequested() { + assertTrue(artworkPaths("../bad", "../worse").isEmpty()) + assertNull(loadArtwork(cache(), "../bad", null) { fail("should not fetch"); null }) + } + + @Test fun itMovesOnWhenAPictureIsMissing() { + val asked = mutableListOf() + val found = loadArtwork(cache(), "ep", "show") { path -> asked += path; if (path.contains("Primary")) png else null } + assertArrayEquals(png, found) + assertEquals(3, asked.size) + } + + @Test fun aFailureMeansNoPicture() { + assertNull(loadArtwork(cache(), "ep", "show") { throw EmbyCheckException("no") }) + assertNull(loadArtwork(cache(), "ep", "show") { ByteArray(0) }) + } + + @Test fun aShowsPictureIsOnlyFetchedOnce() { + val cache = cache() + var fetches = 0 + repeat(3) { i -> assertArrayEquals(png, loadArtwork(cache, "ep$i", "show") { fetches++; png }) } + assertEquals(1, fetches) + } + + @Test fun theCacheStaysBounded() { + val dir = folder.newFolder(); val cache = ArtworkCache(dir) + repeat(160) { cache.put("k$it", png) } + assertTrue(File(dir.path).listFiles()!!.size <= 150) + } + + @Test fun aPlannedChannelKnowsWhichShowIsOn() { + val minute = 60_000L + val media = { id: String -> ScheduledMedia(SampleItem(id, "Episode", 1, "", id, minute), "s-$id", minute) } + val channel = ScheduledChannel(SampleChannel(listOf(media("x"), media("y")), "Paradox", 152), ChannelPlan(listOf(ScheduleEntry("show", EpisodeMark(1, 1), media("e"), 1_000_000L)), ScheduleCursor())) + assertEquals("show", channel.seriesIdAt(1_000_000L)) + assertNull(channel.seriesIdAt(5L)) + } +}