From 1692965cba9a1f4d4e4aeac0833d7e9d4dc5a538 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:13:18 +0200 Subject: [PATCH 1/7] feat: read the app releases published on GitHub --- .../video/typetype/tv/data/net/HttpClients.kt | 5 + .../typetype/tv/data/update/AppVersion.kt | 26 +++++ .../typetype/tv/data/update/ReleaseFeed.kt | 94 +++++++++++++++++++ .../typetype/tv/data/update/ReleaseNotes.kt | 57 +++++++++++ .../typetype/tv/data/update/UpdateOffers.kt | 26 +++++ .../tv/data/update/ReleaseFeedTest.kt | 88 +++++++++++++++++ .../tv/data/update/ReleaseNotesTest.kt | 36 +++++++ .../tv/data/update/UpdateOffersTest.kt | 54 +++++++++++ 8 files changed, 386 insertions(+) create mode 100644 core/data/src/main/java/video/typetype/tv/data/update/AppVersion.kt create mode 100644 core/data/src/main/java/video/typetype/tv/data/update/ReleaseFeed.kt create mode 100644 core/data/src/main/java/video/typetype/tv/data/update/ReleaseNotes.kt create mode 100644 core/data/src/main/java/video/typetype/tv/data/update/UpdateOffers.kt create mode 100644 core/data/src/test/java/video/typetype/tv/data/update/ReleaseFeedTest.kt create mode 100644 core/data/src/test/java/video/typetype/tv/data/update/ReleaseNotesTest.kt create mode 100644 core/data/src/test/java/video/typetype/tv/data/update/UpdateOffersTest.kt diff --git a/core/data/src/main/java/video/typetype/tv/data/net/HttpClients.kt b/core/data/src/main/java/video/typetype/tv/data/net/HttpClients.kt index c66b01f..b570ed4 100644 --- a/core/data/src/main/java/video/typetype/tv/data/net/HttpClients.kt +++ b/core/data/src/main/java/video/typetype/tv/data/net/HttpClients.kt @@ -89,6 +89,11 @@ object HttpClients { .addInterceptor(UserAgentInterceptor(USER_AGENT)) .build() + // Release checks and downloads go to GitHub, never to the instance, so they carry no account and no pin. + fun updates(): OkHttpClient = base(PinTrust()) + .addInterceptor(UserAgentInterceptor(USER_AGENT)) + .build() + fun subtitles(): OkHttpClient = base(PinTrust()) .addInterceptor(UserAgentInterceptor(USER_AGENT)) .build() diff --git a/core/data/src/main/java/video/typetype/tv/data/update/AppVersion.kt b/core/data/src/main/java/video/typetype/tv/data/update/AppVersion.kt new file mode 100644 index 0000000..954eaa8 --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/update/AppVersion.kt @@ -0,0 +1,26 @@ +package video.typetype.tv.data.update + +// A version as the build and the release workflow write it: "x.y.z", or "x.y.z-beta.n" for a beta, which comes before +// the stable release of the same number. +data class AppVersion(val major: Int, val minor: Int, val patch: Int, val beta: Int? = null) : Comparable { + val isBeta: Boolean get() = beta != null + + override fun compareTo(other: AppVersion): Int = + compareValuesBy(this, other, { it.major }, { it.minor }, { it.patch }, { it.beta ?: Int.MAX_VALUE }) + + override fun toString(): String = "$major.$minor.$patch" + (beta?.let { "-beta.$it" } ?: "") + + companion object { + private val PATTERN = Regex("""v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?""") + + fun parse(text: String): AppVersion? { + val (major, minor, patch, beta) = PATTERN.matchEntire(text.trim())?.destructured ?: return null + return AppVersion( + major.toIntOrNull() ?: return null, + minor.toIntOrNull() ?: return null, + patch.toIntOrNull() ?: return null, + if (beta.isEmpty()) null else beta.toIntOrNull() ?: return null, + ) + } + } +} diff --git a/core/data/src/main/java/video/typetype/tv/data/update/ReleaseFeed.kt b/core/data/src/main/java/video/typetype/tv/data/update/ReleaseFeed.kt new file mode 100644 index 0000000..1dfb029 --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/update/ReleaseFeed.kt @@ -0,0 +1,94 @@ +package video.typetype.tv.data.update + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import video.typetype.tv.data.net.ApiJson +import java.io.IOException + +// What the release workflow attaches to each release next to the APK. +@Serializable +data class UpdateManifest( + val packageName: String, + val versionName: String, + val versionCode: Long, + val apkAsset: String, + val sha256: String, +) + +@Serializable +private data class ReleaseDto( + @SerialName("tag_name") val tagName: String, + val draft: Boolean = false, + val body: String? = null, + val assets: List = emptyList(), +) + +@Serializable +private data class AssetDto(val name: String, @SerialName("browser_download_url") val url: String) + +interface ReleaseSource { + suspend fun releases(): List + + suspend fun manifest(release: AppRelease): UpdateManifest +} + +// The releases of the app on GitHub. One call lists them with their notes, so the update panel can say what every +// version since the installed one brings; GitHub allows 60 such calls an hour to a network without an account. +class ReleaseFeed( + private val client: OkHttpClient, + private val api: HttpUrl = GITHUB_API.toHttpUrl(), + private val repository: String = REPOSITORY, +) : ReleaseSource { + override suspend fun releases(): List { + val url = api.newBuilder() + .addPathSegments("repos/$repository/releases") + .addQueryParameter("per_page", PAGE_SIZE.toString()) + .build() + return get(url, ListSerializer(ReleaseDto.serializer())).mapNotNull { it.toRelease() } + } + + override suspend fun manifest(release: AppRelease): UpdateManifest = get(release.manifestUrl.toHttpUrl(), UpdateManifest.serializer()) + + private suspend fun get(url: HttpUrl, serializer: KSerializer): T = withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).header("Accept", "application/vnd.github+json").build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("Release check answered HTTP ${response.code}") + try { + ApiJson.instance.decodeFromString(serializer, response.body.string()) + } catch (error: SerializationException) { + throw IOException("Release check answered an unreadable list", error) + } + } + } + + // A release is only offered once its APK and manifest are both there: the workflow uploads them together. + private fun ReleaseDto.toRelease(): AppRelease? { + if (draft) return null + val version = AppVersion.parse(tagName) ?: return null + val files = assets.associate { it.name to it.url } + return AppRelease( + version = version, + tag = tagName, + notes = body.orEmpty(), + apkUrl = files["TypeType-TV-$tagName.apk"] ?: return null, + manifestUrl = files[MANIFEST] ?: return null, + ) + } + + companion object { + const val GITHUB_API = "https://api.github.com/" + const val REPOSITORY = "TypeType-Video/TypeType-TV" + const val RELEASES_PAGE = "https://github.com/$REPOSITORY/releases" + private const val MANIFEST = "typetype-update.json" + private const val PAGE_SIZE = 30 + } +} diff --git a/core/data/src/main/java/video/typetype/tv/data/update/ReleaseNotes.kt b/core/data/src/main/java/video/typetype/tv/data/update/ReleaseNotes.kt new file mode 100644 index 0000000..75b35ea --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/update/ReleaseNotes.kt @@ -0,0 +1,57 @@ +package video.typetype.tv.data.update + +sealed interface NoteBlock { + val text: String + + data class Heading(override val text: String) : NoteBlock + + data class Item(override val text: String) : NoteBlock + + data class Paragraph(override val text: String) : NoteBlock +} + +// The release notes as the TV shows them: the headings, list items and paragraphs of the small Markdown they are +// written in. Lines wrapped in the file join back into one block, and inline marks are dropped. +object ReleaseNotes { + private val HEADING = Regex("""^#{1,6}\s+(.*)$""") + private val ITEM = Regex("""^\s*[-*]\s+(.*)$""") + private val LINK = Regex("""\[([^\]]*)]\([^)]*\)""") + private val EMPHASIS = Regex("""\*\*|__|`""") + + fun parse(markdown: String): List { + val blocks = mutableListOf() + var open: StringBuilder? = null + var openIsItem = false + fun close() { + val text = open?.let(::clean)?.takeIf { it.isNotEmpty() } + if (text != null) blocks += if (openIsItem) NoteBlock.Item(text) else NoteBlock.Paragraph(text) + open = null + } + markdown.lineSequence().forEach { line -> + val heading = HEADING.find(line) + val item = ITEM.find(line) + when { + line.isBlank() -> close() + heading != null -> { + close() + blocks += NoteBlock.Heading(clean(heading.groupValues[1])) + } + item != null -> { + close() + open = StringBuilder(item.groupValues[1]) + openIsItem = true + } + open != null -> open?.append(' ')?.append(line.trim()) + else -> { + open = StringBuilder(line.trim()) + openIsItem = false + } + } + } + close() + return blocks + } + + private fun clean(text: CharSequence): String = + EMPHASIS.replace(LINK.replace(text) { it.groupValues[1] }, "").replace(Regex("""\s+"""), " ").trim() +} diff --git a/core/data/src/main/java/video/typetype/tv/data/update/UpdateOffers.kt b/core/data/src/main/java/video/typetype/tv/data/update/UpdateOffers.kt new file mode 100644 index 0000000..92581e6 --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/update/UpdateOffers.kt @@ -0,0 +1,26 @@ +package video.typetype.tv.data.update + +enum class UpdateChannel(val wire: String) { Stable("stable"), Beta("beta") } + +// One published version of the app, with what its release page says and the files it carries. +data class AppRelease( + val version: AppVersion, + val tag: String, + val notes: String, + val apkUrl: String, + val manifestUrl: String, +) + +// The release to install, and every release newer than the installed one, newest first, whose notes say what changes. +data class UpdateOffer(val target: AppRelease, val releases: List) + +object UpdateOffers { + // The stable channel only ever offers stable releases. The beta channel offers whichever is newest, so a beta box + // also moves on to the stable release that ends a beta. + fun pick(installed: AppVersion, channel: UpdateChannel, releases: List): UpdateOffer? { + val newer = releases + .filter { it.version > installed && (channel == UpdateChannel.Beta || !it.version.isBeta) } + .sortedByDescending { it.version } + return newer.firstOrNull()?.let { UpdateOffer(it, newer) } + } +} diff --git a/core/data/src/test/java/video/typetype/tv/data/update/ReleaseFeedTest.kt b/core/data/src/test/java/video/typetype/tv/data/update/ReleaseFeedTest.kt new file mode 100644 index 0000000..7da0468 --- /dev/null +++ b/core/data/src/test/java/video/typetype/tv/data/update/ReleaseFeedTest.kt @@ -0,0 +1,88 @@ +package video.typetype.tv.data.update + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class ReleaseFeedTest { + private lateinit var server: MockWebServer + private lateinit var feed: ReleaseFeed + + @Before + fun start() { + server = MockWebServer().apply { start() } + feed = ReleaseFeed(OkHttpClient(), server.url("/"), "owner/app") + } + + @After + fun stop() = server.shutdown() + + private fun asset(name: String) = """{"name":"$name","browser_download_url":"${server.url("/files/$name")}"}""" + + @Test + fun releasesWithTheirFilesAreRead() = runBlocking { + server.enqueue( + MockResponse().setBody( + """[ + {"tag_name":"v0.1.1-beta.1","prerelease":true,"body":"Beta","assets":[ + ${asset("TypeType-TV-v0.1.1-beta.1.apk")},${asset("typetype-update.json")}]}, + {"tag_name":"v0.1.0","body":null,"assets":[ + ${asset("TypeType-TV-v0.1.0.apk")},${asset("typetype-update.json")}]} + ]""", + ), + ) + val releases = feed.releases() + assertEquals(listOf("v0.1.1-beta.1", "v0.1.0"), releases.map { it.tag }) + assertEquals("Beta", releases.first().notes) + assertEquals("", releases.last().notes) + assertEquals(server.url("/files/TypeType-TV-v0.1.0.apk").toString(), releases.last().apkUrl) + val request = server.takeRequest() + assertEquals("/repos/owner/app/releases?per_page=30", request.path) + } + + @Test + fun draftsUnknownTagsAndReleasesStillUploadingAreLeftOut() = runBlocking { + server.enqueue( + MockResponse().setBody( + """[ + {"tag_name":"v0.3.0","draft":true,"assets":[${asset("TypeType-TV-v0.3.0.apk")},${asset("typetype-update.json")}]}, + {"tag_name":"nightly","assets":[${asset("TypeType-TV-nightly.apk")},${asset("typetype-update.json")}]}, + {"tag_name":"v0.2.0","assets":[${asset("TypeType-TV-v0.2.0.apk")}]} + ]""", + ), + ) + assertEquals(emptyList(), feed.releases()) + } + + @Test + fun theManifestOfAReleaseIsRead() = runBlocking { + server.enqueue( + MockResponse().setBody( + """{"schemaVersion":1,"channel":"stable","packageName":"video.typetype.tv","versionName":"0.1.0", + "versionCode":10099,"apkAsset":"TypeType-TV-v0.1.0.apk","sha256":"abc"}""", + ), + ) + val release = AppRelease(AppVersion(0, 1, 0), "v0.1.0", "", "", server.url("/files/typetype-update.json").toString()) + assertEquals(UpdateManifest("video.typetype.tv", "0.1.0", 10099, "TypeType-TV-v0.1.0.apk", "abc"), feed.manifest(release)) + } + + @Test + fun aRefusedCheckIsAnIoFailure(): Unit = runBlocking { + server.enqueue(MockResponse().setResponseCode(403)) + server.enqueue(MockResponse().setBody("")) + repeat(2) { + try { + feed.releases() + fail("The check should fail") + } catch (_: IOException) { + } + } + } +} diff --git a/core/data/src/test/java/video/typetype/tv/data/update/ReleaseNotesTest.kt b/core/data/src/test/java/video/typetype/tv/data/update/ReleaseNotesTest.kt new file mode 100644 index 0000000..df03c17 --- /dev/null +++ b/core/data/src/test/java/video/typetype/tv/data/update/ReleaseNotesTest.kt @@ -0,0 +1,36 @@ +package video.typetype.tv.data.update + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReleaseNotesTest { + @Test + fun headingsItemsAndParagraphsComeOutAsBlocks() { + val notes = """ + First public release of TypeType TV, the Android TV client + for TypeType. + + ## Highlights + + - Home feed and search. + - Playback up to 4K with `AVC` or **VP9**. + When a decoder fails, the app remembers it. + * A [Performance](https://example.org) section. + """.trimIndent() + assertEquals( + listOf( + NoteBlock.Paragraph("First public release of TypeType TV, the Android TV client for TypeType."), + NoteBlock.Heading("Highlights"), + NoteBlock.Item("Home feed and search."), + NoteBlock.Item("Playback up to 4K with AVC or VP9. When a decoder fails, the app remembers it."), + NoteBlock.Item("A Performance section."), + ), + ReleaseNotes.parse(notes), + ) + } + + @Test + fun emptyNotesGiveNoBlocks() { + assertEquals(emptyList(), ReleaseNotes.parse("\n \n")) + } +} diff --git a/core/data/src/test/java/video/typetype/tv/data/update/UpdateOffersTest.kt b/core/data/src/test/java/video/typetype/tv/data/update/UpdateOffersTest.kt new file mode 100644 index 0000000..fb36aea --- /dev/null +++ b/core/data/src/test/java/video/typetype/tv/data/update/UpdateOffersTest.kt @@ -0,0 +1,54 @@ +package video.typetype.tv.data.update + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdateOffersTest { + private fun version(text: String) = requireNotNull(AppVersion.parse(text)) + + private fun release(tag: String) = AppRelease(version(tag), tag, "notes $tag", "apk $tag", "manifest $tag") + + private val published = listOf("v0.1.0", "v0.1.1-beta.1", "v0.1.1-beta.2", "v0.1.1", "v0.2.0-beta.1").map(::release) + + @Test + fun aBetaComesBeforeTheStableReleaseOfTheSameNumber() { + val ordered = listOf("0.1.1", "0.1.1-beta.2", "0.1.0", "0.1.1-beta.10", "0.10.0").map(::version).sorted() + assertEquals(listOf("0.1.0", "0.1.1-beta.2", "0.1.1-beta.10", "0.1.1", "0.10.0"), ordered.map { it.toString() }) + } + + @Test + fun onlyTheFormatsOfTheReleaseWorkflowAreRead() { + assertEquals(AppVersion(1, 2, 3, 4), AppVersion.parse("v1.2.3-beta.4")) + assertNull(AppVersion.parse("1.2")) + assertNull(AppVersion.parse("1.2.3-rc.1")) + assertNull(AppVersion.parse("99999999999.0.0")) + } + + @Test + fun theStableChannelSkipsBetas() { + val offer = UpdateOffers.pick(version("0.1.0"), UpdateChannel.Stable, published) + assertEquals("v0.1.1", offer?.target?.tag) + assertEquals(listOf("v0.1.1"), offer?.releases?.map { it.tag }) + } + + @Test + fun theBetaChannelTakesTheNewestAndListsEveryNewerRelease() { + val offer = UpdateOffers.pick(version("0.1.1-beta.1"), UpdateChannel.Beta, published) + assertEquals("v0.2.0-beta.1", offer?.target?.tag) + assertEquals(listOf("v0.2.0-beta.1", "v0.1.1", "v0.1.1-beta.2"), offer?.releases?.map { it.tag }) + } + + @Test + fun aBetaBoxOnTheStableChannelWaitsForTheNextStableRelease() { + assertEquals("v0.1.1", UpdateOffers.pick(version("0.1.1-beta.2"), UpdateChannel.Stable, published)?.target?.tag) + assertNull(UpdateOffers.pick(version("0.2.0-beta.1"), UpdateChannel.Stable, published)) + } + + @Test + fun nothingIsOfferedToTheNewestVersion() { + assertNull(UpdateOffers.pick(version("0.2.0-beta.1"), UpdateChannel.Beta, published)) + assertTrue(UpdateOffers.pick(version("0.1.1"), UpdateChannel.Stable, emptyList()) == null) + } +} From 5e83f69dafb7e86f70d90cd26f521351e84f0b90 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:13:18 +0200 Subject: [PATCH 2/7] feat: download an app update and check its checksum --- .../tv/data/update/UpdateDownloader.kt | 65 +++++++++++++++++ .../tv/data/update/UpdateDownloaderTest.kt | 72 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 core/data/src/main/java/video/typetype/tv/data/update/UpdateDownloader.kt create mode 100644 core/data/src/test/java/video/typetype/tv/data/update/UpdateDownloaderTest.kt diff --git a/core/data/src/main/java/video/typetype/tv/data/update/UpdateDownloader.kt b/core/data/src/main/java/video/typetype/tv/data/update/UpdateDownloader.kt new file mode 100644 index 0000000..af22520 --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/update/UpdateDownloader.kt @@ -0,0 +1,65 @@ +package video.typetype.tv.data.update + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.IOException +import java.security.MessageDigest + +class UpdateVerificationException : IOException("The downloaded update does not match its checksum") + +fun interface ApkDownload { + suspend fun download(url: String, sha256: String, target: File, onProgress: (Float) -> Unit) +} + +// Streams the APK of an update to a file, a buffer at a time so the 128 MB heap never holds it, and keeps it only +// when its SHA-256 is the one the release manifest gives. +class UpdateDownloader(private val client: OkHttpClient) : ApkDownload { + override suspend fun download(url: String, sha256: String, target: File, onProgress: (Float) -> Unit): Unit = + withContext(Dispatchers.IO) { save(url, sha256, target, onProgress) } + + private fun CoroutineScope.save(url: String, sha256: String, target: File, onProgress: (Float) -> Unit) { + target.parentFile?.mkdirs() + val partial = File(target.path + ".part") + try { + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw IOException("Update download answered HTTP ${response.code}") + val total = response.body.contentLength() + val digest = MessageDigest.getInstance("SHA-256") + var copied = 0L + var reported = -1 + response.body.byteStream().use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + ensureActive() + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + digest.update(buffer, 0, read) + copied += read + val percent = if (total > 0) (copied * 100 / total).toInt() else -1 + if (percent != reported) { + reported = percent + onProgress(percent.coerceAtLeast(0) / 100f) + } + } + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (!actual.equals(sha256.trim(), ignoreCase = true)) throw UpdateVerificationException() + } + if (!partial.renameTo(target)) throw IOException("The downloaded update could not be kept") + } finally { + partial.delete() + } + } + + private companion object { + const val BUFFER_BYTES = 64 * 1024 + } +} diff --git a/core/data/src/test/java/video/typetype/tv/data/update/UpdateDownloaderTest.kt b/core/data/src/test/java/video/typetype/tv/data/update/UpdateDownloaderTest.kt new file mode 100644 index 0000000..d2ba44d --- /dev/null +++ b/core/data/src/test/java/video/typetype/tv/data/update/UpdateDownloaderTest.kt @@ -0,0 +1,72 @@ +package video.typetype.tv.data.update + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.IOException +import java.security.MessageDigest + +class UpdateDownloaderTest { + @get:Rule + val folder = TemporaryFolder() + + private lateinit var server: MockWebServer + private val downloader = UpdateDownloader(OkHttpClient()) + private val apk = ByteArray(300_000) { (it % 251).toByte() } + private val sha256 = MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) } + + @Before + fun start() { + server = MockWebServer().apply { start() } + } + + @After + fun stop() = server.shutdown() + + @Test + fun aMatchingDownloadIsKeptAndItsProgressReachesTheEnd() = runBlocking { + server.enqueue(MockResponse().setBody(Buffer().write(apk))) + val target = folder.root.resolve("update/app.apk") + val progress = mutableListOf() + downloader.download(server.url("/app.apk").toString(), sha256.uppercase(), target) { progress += it } + assertArrayEquals(apk, target.readBytes()) + assertEquals(1f, progress.last()) + assertEquals(progress.sorted(), progress) + } + + @Test + fun aDownloadThatDoesNotMatchItsChecksumIsThrownAway() = runBlocking { + server.enqueue(MockResponse().setBody(Buffer().write(apk))) + val target = folder.root.resolve("app.apk") + try { + downloader.download(server.url("/app.apk").toString(), "00".repeat(32), target) {} + fail("A wrong checksum must fail") + } catch (_: UpdateVerificationException) { + } + assertFalse(target.exists()) + assertEquals(emptyList(), folder.root.list().orEmpty().toList()) + } + + @Test + fun aMissingFileFailsWithoutLeavingAnything() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404)) + val target = folder.root.resolve("app.apk") + try { + downloader.download(server.url("/app.apk").toString(), sha256, target) {} + fail("A missing file must fail") + } catch (_: IOException) { + } + assertFalse(target.exists()) + } +} From 270597ac90dc4f3ebfa02b8163109edfb8ceb16c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:13:18 +0200 Subject: [PATCH 3/7] feat: let each box choose its update channel --- .../java/video/typetype/tv/AppContainer.kt | 4 +- .../typetype/tv/data/device/DeviceSection.kt | 17 +++++ .../typetype/tv/data/device/DeviceStore.kt | 1 + .../tv/data/device/PerformanceSettings.kt | 11 ++- .../typetype/tv/data/device/UpdateSettings.kt | 70 +++++++++++++++++++ .../tv/data/remote/RemoteContentRepository.kt | 9 +-- .../tv/data/device/UpdateSettingsTest.kt | 69 ++++++++++++++++++ .../resources/src/main/res/values/strings.xml | 9 +++ .../tv/ui/settings/SettingItemIcons.kt | 6 ++ .../tv/ui/settings/SettingsGroupIcons.kt | 2 + 10 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 core/data/src/main/java/video/typetype/tv/data/device/DeviceSection.kt create mode 100644 core/data/src/main/java/video/typetype/tv/data/device/UpdateSettings.kt create mode 100644 core/data/src/test/java/video/typetype/tv/data/device/UpdateSettingsTest.kt diff --git a/app/src/main/java/video/typetype/tv/AppContainer.kt b/app/src/main/java/video/typetype/tv/AppContainer.kt index d346615..0a35c2c 100644 --- a/app/src/main/java/video/typetype/tv/AppContainer.kt +++ b/app/src/main/java/video/typetype/tv/AppContainer.kt @@ -6,6 +6,7 @@ import video.typetype.tv.data.ContentRepository import video.typetype.tv.data.PresenceReport import video.typetype.tv.data.device.PerformanceSettings import video.typetype.tv.data.device.PreferencesDeviceStore +import video.typetype.tv.data.device.UpdateSettings import video.typetype.tv.data.dto.UserProfileDto import video.typetype.tv.data.net.AndroidConnectivity import video.typetype.tv.data.net.Connectivity @@ -39,6 +40,7 @@ class AppContainer(context: Context) { private val deviceProfile by lazy { DeviceProfileStore(appContext, appVersion) } val performance by lazy { PerformanceSettings(PreferencesDeviceStore(appContext)) } + val updateSettings by lazy { UpdateSettings(PreferencesDeviceStore(appContext, PreferencesDeviceStore.UPDATES)) } fun warmUp() { Thread({ secrets.warmUp() }, "secrets-warmup").start() @@ -77,7 +79,7 @@ class AppContainer(context: Context) { watch = backend.watch, userSettings = userSettings, access = access, - performance = performance, + deviceSections = listOf(performance, updateSettings), ) private val codecs: CodecSupport by lazy { deviceCodecs() } diff --git a/core/data/src/main/java/video/typetype/tv/data/device/DeviceSection.kt b/core/data/src/main/java/video/typetype/tv/data/device/DeviceSection.kt new file mode 100644 index 0000000..6b52594 --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/device/DeviceSection.kt @@ -0,0 +1,17 @@ +package video.typetype.tv.data.device + +import video.typetype.tv.data.SettingsGroup +import video.typetype.tv.data.remote.TextProvider + +// A settings section kept on this box, shown among the account's sections. +interface DeviceSection { + fun addTo(groups: List, texts: TextProvider): List + + // Returns false for a setting this section does not keep. + fun update(id: String, value: String): Boolean +} + +internal fun List.insertBefore(groupId: String, group: SettingsGroup): List { + val at = indexOfFirst { it.id == groupId }.takeIf { it >= 0 } ?: size + return take(at) + group + drop(at) +} diff --git a/core/data/src/main/java/video/typetype/tv/data/device/DeviceStore.kt b/core/data/src/main/java/video/typetype/tv/data/device/DeviceStore.kt index 4de5142..4651ba5 100644 --- a/core/data/src/main/java/video/typetype/tv/data/device/DeviceStore.kt +++ b/core/data/src/main/java/video/typetype/tv/data/device/DeviceStore.kt @@ -27,5 +27,6 @@ class PreferencesDeviceStore(context: Context, name: String = PERFORMANCE) : Dev companion object { const val PERFORMANCE = "performance" const val DECODERS = "decoders" + const val UPDATES = "updates" } } diff --git a/core/data/src/main/java/video/typetype/tv/data/device/PerformanceSettings.kt b/core/data/src/main/java/video/typetype/tv/data/device/PerformanceSettings.kt index 7668def..2b58957 100644 --- a/core/data/src/main/java/video/typetype/tv/data/device/PerformanceSettings.kt +++ b/core/data/src/main/java/video/typetype/tv/data/device/PerformanceSettings.kt @@ -20,18 +20,15 @@ data class Performance( // What a slow box can turn off to stay smooth. The choice belongs to the box, not to the account: a strong // television and a small stick signed in to the same account each keep their own. -class PerformanceSettings(private val store: DeviceStore) { +class PerformanceSettings(private val store: DeviceStore) : DeviceSection { private val state = MutableStateFlow(read()) val current: StateFlow = state.asStateFlow() // The section sits with the everyday ones, before the account and the server. - fun addTo(groups: List, texts: TextProvider): List { - val at = groups.indexOfFirst { it.id == BEFORE_GROUP }.takeIf { it >= 0 } ?: groups.size - return groups.take(at) + group(texts) + groups.drop(at) - } + override fun addTo(groups: List, texts: TextProvider): List = + groups.insertBefore(BEFORE_GROUP, group(texts)) - // Returns false for a setting kept on the server. - fun update(id: String, value: String): Boolean { + override fun update(id: String, value: String): Boolean { when (id) { in FLAGS -> store.putFlag(id, value.toBoolean()) INTRO -> store.putText(id, Intro.entries.firstOrNull { it.wire == value }?.wire ?: return false) diff --git a/core/data/src/main/java/video/typetype/tv/data/device/UpdateSettings.kt b/core/data/src/main/java/video/typetype/tv/data/device/UpdateSettings.kt new file mode 100644 index 0000000..9c048f9 --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/device/UpdateSettings.kt @@ -0,0 +1,70 @@ +package video.typetype.tv.data.device + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import video.typetype.tv.data.SettingItem +import video.typetype.tv.data.SettingsGroup +import video.typetype.tv.data.remote.TextProvider +import video.typetype.tv.data.update.UpdateChannel +import video.typetype.tv.resources.R + +data class UpdatePreferences( + val channel: UpdateChannel = UpdateChannel.Stable, + val checkOnStart: Boolean = true, +) + +// How this box follows new versions of the app. The choice belongs to the box: a test box can follow the betas +// while the living-room TV stays on stable releases. +class UpdateSettings(private val store: DeviceStore) : DeviceSection { + private val state = MutableStateFlow(read()) + val current: StateFlow = state.asStateFlow() + + override fun addTo(groups: List, texts: TextProvider): List = + groups.insertBefore(BEFORE_GROUP, group(texts)) + + override fun update(id: String, value: String): Boolean { + when (id) { + CHECK_ON_START -> store.putFlag(id, value.toBoolean()) + CHANNEL -> store.putText(id, UpdateChannel.entries.firstOrNull { it.wire == value }?.wire ?: return false) + else -> return false + } + state.value = read() + return true + } + + private fun group(texts: TextProvider): SettingsGroup { + val now = state.value + return SettingsGroup( + id = GROUP, + titleRes = R.string.settings_group_updates, + items = listOf( + SettingItem.Action(CHECK_NOW, R.string.settings_check_updates, R.string.settings_check_updates_summary), + SettingItem.Choice( + id = CHANNEL, + titleRes = R.string.settings_update_channel, + options = listOf(R.string.settings_update_channel_stable, R.string.settings_update_channel_beta).map(texts::text), + defaultIndex = now.channel.ordinal, + wireValues = UpdateChannel.entries.map { it.wire }, + summaryRes = R.string.settings_update_channel_summary, + ), + SettingItem.Toggle(CHECK_ON_START, R.string.settings_update_on_start, R.string.settings_update_on_start_summary, now.checkOnStart), + ), + ) + } + + private fun read() = UpdatePreferences( + channel = store.text(CHANNEL, UpdateChannel.Stable.wire).let { wire -> + UpdateChannel.entries.firstOrNull { it.wire == wire } ?: UpdateChannel.Stable + }, + checkOnStart = store.flag(CHECK_ON_START, true), + ) + + companion object { + const val CHECK_NOW = "check-updates" + private const val GROUP = "updates" + private const val BEFORE_GROUP = "about" + private const val CHANNEL = "update-channel" + private const val CHECK_ON_START = "update-on-start" + } +} diff --git a/core/data/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt b/core/data/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt index 4956c7e..f233dcf 100644 --- a/core/data/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt +++ b/core/data/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt @@ -35,7 +35,7 @@ import video.typetype.tv.data.dto.BlockChannelRequest import video.typetype.tv.data.dto.BlockVideoRequest import video.typetype.tv.data.dto.StreamDto import video.typetype.tv.data.net.ApiException -import video.typetype.tv.data.device.PerformanceSettings +import video.typetype.tv.data.device.DeviceSection import video.typetype.tv.data.net.ServerWatch import video.typetype.tv.data.session.StreamAccess @@ -49,7 +49,7 @@ class RemoteContentRepository( private val watch: ServerWatch, private val userSettings: RemoteUserSettings, access: StreamAccess, - private val performance: PerformanceSettings, + private val deviceSections: List, private val clock: () -> Long = System::currentTimeMillis, ) : ContentRepository { private val streams = StreamStore({ url -> access.anonymousFirst { api.streamFor(url, it) } }, clock) @@ -133,7 +133,8 @@ class RemoteContentRepository( .distinct() .take(IDEA_LIMIT) - override suspend fun settings(): List = performance.addTo(watch.watch { settingsSource.groups(api) }, texts) + override suspend fun settings(): List = + deviceSections.fold(watch.watch { settingsSource.groups(api) }) { groups, section -> section.addTo(groups, texts) } override suspend fun startupPreferences(): StartupPreferences = userSettings.startupPreferences() @@ -174,7 +175,7 @@ class RemoteContentRepository( } override suspend fun updateSetting(id: String, value: String) { - if (performance.update(id, value)) return + if (deviceSections.any { it.update(id, value) }) return settingsSource.update(api, id, value) userSettings.invalidate() } diff --git a/core/data/src/test/java/video/typetype/tv/data/device/UpdateSettingsTest.kt b/core/data/src/test/java/video/typetype/tv/data/device/UpdateSettingsTest.kt new file mode 100644 index 0000000..9fb22fa --- /dev/null +++ b/core/data/src/test/java/video/typetype/tv/data/device/UpdateSettingsTest.kt @@ -0,0 +1,69 @@ +package video.typetype.tv.data.device + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import video.typetype.tv.data.SettingItem +import video.typetype.tv.data.SettingsGroup +import video.typetype.tv.data.remote.TextProvider +import video.typetype.tv.data.update.UpdateChannel + +class UpdateSettingsTest { + private class MemoryStore : DeviceStore { + val values = mutableMapOf() + + override fun flag(key: String, fallback: Boolean) = values[key] as? Boolean ?: fallback + + override fun putFlag(key: String, value: Boolean) { + values[key] = value + } + + override fun text(key: String, fallback: String) = values[key] as? String ?: fallback + + override fun putText(key: String, value: String) { + values[key] = value + } + } + + private val texts = object : TextProvider { + override fun text(id: Int) = "text-$id" + } + private val store = MemoryStore() + private val settings = UpdateSettings(store) + + @Test + fun aBoxFollowsStableReleasesAndChecksOnStartUntilToldOtherwise() { + assertEquals(UpdatePreferences(), settings.current.value) + } + + @Test + fun theChannelAndTheStartupCheckAreKeptOnTheBox() { + assertTrue(settings.update("update-channel", "beta")) + assertTrue(settings.update("update-on-start", "false")) + assertFalse(settings.update("update-channel", "nightly")) + val reread = UpdateSettings(store).current.value + assertEquals(UpdateChannel.Beta, reread.channel) + assertFalse(reread.checkOnStart) + } + + @Test + fun otherSettingsAreLeftAlone() { + assertFalse(settings.update("intro", "short")) + assertTrue(store.values.isEmpty()) + } + + @Test + fun theSectionComesBeforeAboutWithTheCurrentChoices() { + settings.update("update-channel", "beta") + val groups = listOf("playback", "account", "about").map { SettingsGroup(it, 0, emptyList()) } + val placed = settings.addTo(groups, texts) + assertEquals(listOf("playback", "account", "updates", "about"), placed.map { it.id }) + val items = placed[2].items + assertEquals("check-updates", items.filterIsInstance().single().id) + val channel = items.filterIsInstance().single() + assertEquals(listOf("stable", "beta"), channel.wireValues) + assertEquals(1, channel.defaultIndex) + assertTrue(items.filterIsInstance().single().defaultValue) + } +} diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 544cca1..7fd8aa4 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -303,6 +303,15 @@ Off Check the video decoders again Gives another chance to the video formats that failed on this TV. + Updates + Check for updates + Looks for a newer version of TypeType TV now. + Update channel + Betas bring new features first and may still have bugs. + Stable + Beta + Check when the app opens + Asks GitHub, where TypeType TV is published, when the app opens and every few hours while it stays open. Blocked channels Select to unblock this channel Block channel diff --git a/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingItemIcons.kt b/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingItemIcons.kt index fbc40a1..9cc434e 100644 --- a/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingItemIcons.kt +++ b/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingItemIcons.kt @@ -30,9 +30,12 @@ import androidx.compose.material.icons.rounded.SmartDisplay import androidx.compose.material.icons.rounded.MovieFilter import androidx.compose.material.icons.rounded.Wallpaper import androidx.compose.material.icons.rounded.Smartphone +import androidx.compose.material.icons.rounded.Science import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.SystemUpdate import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Update import androidx.compose.material.icons.rounded.VerifiedUser import androidx.compose.material.icons.rounded.VideoLibrary import androidx.compose.ui.graphics.vector.ImageVector @@ -62,6 +65,9 @@ internal fun SettingItem.icon(): ImageVector = when { "backdrop" -> Icons.Rounded.Wallpaper "intro" -> Icons.Rounded.MovieFilter "forget-decoders" -> Icons.Rounded.Restore + "check-updates" -> Icons.Rounded.SystemUpdate + "update-channel" -> Icons.Rounded.Science + "update-on-start" -> Icons.Rounded.Update "guest", "name" -> Icons.Rounded.Person "email" -> Icons.Rounded.AlternateEmail "role" -> Icons.Rounded.Badge diff --git a/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingsGroupIcons.kt b/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingsGroupIcons.kt index 5c4d33e..fee5697 100644 --- a/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingsGroupIcons.kt +++ b/feature/settings/src/main/java/video/typetype/tv/ui/settings/SettingsGroupIcons.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.Dns import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.SystemUpdate import androidx.compose.ui.graphics.vector.ImageVector import video.typetype.tv.data.SettingsGroup @@ -19,6 +20,7 @@ internal fun SettingsGroup.icon(): ImageVector = when (id) { "content" -> Icons.Rounded.Dashboard "blocked" -> Icons.Rounded.Block "performance" -> Icons.Rounded.Bolt + "updates" -> Icons.Rounded.SystemUpdate "about" -> Icons.Rounded.Info else -> Icons.Rounded.Settings } From e3d72eb65a16ea144d875d39f9615c7f7c256f5f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:13:18 +0200 Subject: [PATCH 4/7] feat: install an app update through the system installer --- feature/update/build.gradle.kts | 15 +++ feature/update/src/main/AndroidManifest.xml | 6 + .../tv/ui/update/SessionUpdateInstaller.kt | 114 ++++++++++++++++++ .../typetype/tv/ui/update/UpdateInstaller.kt | 23 ++++ settings.gradle.kts | 1 + 5 files changed, 159 insertions(+) create mode 100644 feature/update/build.gradle.kts create mode 100644 feature/update/src/main/AndroidManifest.xml create mode 100644 feature/update/src/main/java/video/typetype/tv/ui/update/SessionUpdateInstaller.kt create mode 100644 feature/update/src/main/java/video/typetype/tv/ui/update/UpdateInstaller.kt diff --git a/feature/update/build.gradle.kts b/feature/update/build.gradle.kts new file mode 100644 index 0000000..a722c82 --- /dev/null +++ b/feature/update/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.typetype.android.compose) +} + +android { + namespace = "video.typetype.tv.feature.update" +} + +dependencies { + implementation(project(":core:ui")) + implementation(libs.androidx.lifecycle.runtime.compose) + + testImplementation(project(":core:testing")) + testImplementation(libs.junit) +} diff --git a/feature/update/src/main/AndroidManifest.xml b/feature/update/src/main/AndroidManifest.xml new file mode 100644 index 0000000..674b74b --- /dev/null +++ b/feature/update/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/feature/update/src/main/java/video/typetype/tv/ui/update/SessionUpdateInstaller.kt b/feature/update/src/main/java/video/typetype/tv/ui/update/SessionUpdateInstaller.kt new file mode 100644 index 0000000..7afbf72 --- /dev/null +++ b/feature/update/src/main/java/video/typetype/tv/ui/update/SessionUpdateInstaller.kt @@ -0,0 +1,114 @@ +package video.typetype.tv.ui.update + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import kotlin.coroutines.resume + +// Installs through a PackageInstaller session, the way Morphe Manager updates itself. From Android 12 the session +// first asks to update without a confirmation, which the system grants when this app installed the running version; +// when it refuses, the session is committed again with the usual confirmation. +class SessionUpdateInstaller(context: Context) : UpdateInstaller { + private val context = context.applicationContext + override val packageName: String = this.context.packageName + + override suspend fun install(apk: File): InstallResult { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val silent = commit(apk, askUser = false) + if (silent !== SILENT_REFUSED) return silent + } + return commit(apk, askUser = true) + } + + private suspend fun commit(apk: File, askUser: Boolean): InstallResult { + val installer = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(packageName) + setSize(apk.length()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction( + if (askUser) PackageInstaller.SessionParams.USER_ACTION_REQUIRED + else PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED, + ) + } + } + val sessionId = try { + installer.createSession(params).also { id -> withContext(Dispatchers.IO) { write(installer, id, apk) } } + } catch (error: IOException) { + return InstallResult.Failed(error.message) + } catch (error: SecurityException) { + return InstallResult.Failed(error.message) + } + return awaitAnswer(installer, sessionId, askUser) + } + + private fun write(installer: PackageInstaller, sessionId: Int, apk: File) { + try { + installer.openSession(sessionId).use { session -> + session.openWrite("base.apk", 0, apk.length()).use { output -> + apk.inputStream().use { it.copyTo(output) } + session.fsync(output) + } + } + } catch (error: IOException) { + installer.abandonSession(sessionId) + throw error + } + } + + private suspend fun awaitAnswer(installer: PackageInstaller, sessionId: Int, askUser: Boolean): InstallResult = + suspendCancellableCoroutine { continuation -> + val action = "$packageName.UPDATE_STATUS.$sessionId" + var shownToUser = askUser + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + shownToUser = true + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ?.let(context::startActivity) + return + } + context.unregisterReceiver(this) + val result = when (status) { + PackageInstaller.STATUS_SUCCESS -> InstallResult.Success + PackageInstaller.STATUS_FAILURE_ABORTED -> if (shownToUser) InstallResult.Cancelled else SILENT_REFUSED + PackageInstaller.STATUS_FAILURE_CONFLICT -> InstallResult.Conflict + else -> InstallResult.Failed(intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)) + } + if (continuation.isActive) continuation.resume(result) + } + } + ContextCompat.registerReceiver(context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED) + continuation.invokeOnCancellation { + runCatching { context.unregisterReceiver(receiver) } + runCatching { installer.abandonSession(sessionId) } + } + // The system fills the status into this intent, so it has to stay mutable from Android 12. + val mutable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 + val status = PendingIntent.getBroadcast( + context, + sessionId, + Intent(action).setPackage(packageName), + PendingIntent.FLAG_UPDATE_CURRENT or mutable, + ) + installer.openSession(sessionId).use { it.commit(status.intentSender) } + } + + private companion object { + // A refusal of the update without confirmation, answered by asking the viewer instead. + val SILENT_REFUSED = InstallResult.Failed("silent update refused") + } +} diff --git a/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateInstaller.kt b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateInstaller.kt new file mode 100644 index 0000000..c7dc52f --- /dev/null +++ b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateInstaller.kt @@ -0,0 +1,23 @@ +package video.typetype.tv.ui.update + +import java.io.File + +sealed interface InstallResult { + data object Success : InstallResult + + // The viewer declined the system's confirmation. + data object Cancelled : InstallResult + + // Android refused to replace the installed app, most often because the new APK is signed by another key. + data object Conflict : InstallResult + + data class Failed(val message: String?) : InstallResult +} + +interface UpdateInstaller { + val packageName: String + + // Hands the APK to the system and waits for its answer. A successful update replaces this app, so the process + // usually ends before the answer arrives. + suspend fun install(apk: File): InstallResult +} diff --git a/settings.gradle.kts b/settings.gradle.kts index bcef50d..4c82e21 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,3 +38,4 @@ include(":feature:settings") include(":feature:collections") include(":feature:detail") include(":feature:player") +include(":feature:update") From 2abbd81c07a3c35bf3d0285d9843bbd3dc0ffdf5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:13:18 +0200 Subject: [PATCH 5/7] feat: check, download and install updates in the background --- .../typetype/tv/ui/update/UpdateController.kt | 170 +++++++++++++++ .../tv/ui/update/UpdateControllerTest.kt | 198 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 feature/update/src/main/java/video/typetype/tv/ui/update/UpdateController.kt create mode 100644 feature/update/src/test/java/video/typetype/tv/ui/update/UpdateControllerTest.kt diff --git a/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateController.kt b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateController.kt new file mode 100644 index 0000000..b7bc1ce --- /dev/null +++ b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateController.kt @@ -0,0 +1,170 @@ +package video.typetype.tv.ui.update + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import video.typetype.tv.data.device.UpdatePreferences +import video.typetype.tv.data.device.UpdateSettings +import video.typetype.tv.data.update.ApkDownload +import video.typetype.tv.data.update.AppVersion +import video.typetype.tv.data.update.ReleaseSource +import video.typetype.tv.data.update.UpdateOffer +import video.typetype.tv.data.update.UpdateOffers +import video.typetype.tv.data.update.UpdateVerificationException +import java.io.File +import java.io.IOException + +enum class UpdateFailure { Download, Checksum, NotThisApp, Signature, Refused } + +sealed interface UpdateState { + data object Idle : UpdateState + + data object Checking : UpdateState + + data object UpToDate : UpdateState + + data object CheckFailed : UpdateState + + data class Available(val offer: UpdateOffer) : UpdateState + + data class Downloading(val offer: UpdateOffer, val progress: Float) : UpdateState + + data class Installing(val offer: UpdateOffer) : UpdateState + + data class Failed(val offer: UpdateOffer, val reason: UpdateFailure) : UpdateState +} + +val UpdateState.offer: UpdateOffer? + get() = when (this) { + is UpdateState.Available -> offer + is UpdateState.Downloading -> offer + is UpdateState.Installing -> offer + is UpdateState.Failed -> offer + else -> null + } + +data class InstalledApp(val packageName: String, val versionName: String, val versionCode: Long) { + val version: AppVersion? = AppVersion.parse(versionName) +} + +// Finds, downloads and installs new versions of the app, and says where that stands. It lives as long as the app: +// a download goes on while the panel is closed, and the top bar shows an update found in the background. +class UpdateController( + private val releases: ReleaseSource, + private val download: ApkDownload, + private val installer: UpdateInstaller, + private val settings: UpdateSettings, + val installed: InstalledApp, + private val apkFile: File, + private val scope: CoroutineScope, + // Off for debug builds: their key differs from the releases', so Android would refuse every update. + private val automatic: Boolean, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val mutableState = MutableStateFlow(UpdateState.Idle) + val state: StateFlow = mutableState.asStateFlow() + private val mutablePanel = MutableStateFlow(false) + val panelOpen: StateFlow = mutablePanel.asStateFlow() + val preferences: StateFlow = settings.current + private var job: Job? = null + private var lastCheckMillis: Long? = null + + init { + apkFile.delete() + scope.launch { + settings.current.map { it.channel }.distinctUntilChanged().drop(1).collect { + if (!busy()) check(quiet = !mutablePanel.value) + } + } + } + + // Called when the app opens and each time it comes back to the front: the first call checks, later ones only + // once the last check is a few hours old. + fun checkIfDue() { + if (!automatic || !settings.current.value.checkOnStart || busy()) return + val last = lastCheckMillis + if (last != null && clock() - last < RECHECK_MILLIS) return + check(quiet = true) + } + + fun checkNow() { + mutablePanel.value = true + if (!busy()) check(quiet = false) + } + + fun openPanel() { + mutablePanel.value = true + } + + fun closePanel() { + mutablePanel.value = false + } + + fun install() { + val offer = mutableState.value.offer ?: return + if (busy()) return + job = scope.launch { mutableState.value = installing(offer) } + } + + // Stops a download, or forgets an installation the system never answered. + fun cancel() { + val offer = mutableState.value.offer ?: return + job?.cancel() + mutableState.value = UpdateState.Available(offer) + } + + private fun busy() = job?.isActive == true && + mutableState.value.let { it is UpdateState.Downloading || it is UpdateState.Installing } + + private fun check(quiet: Boolean) { + job?.cancel() + job = scope.launch { + val before = mutableState.value + if (!quiet) mutableState.value = UpdateState.Checking + lastCheckMillis = clock() + mutableState.value = try { + val version = installed.version + val offer = version?.let { UpdateOffers.pick(it, settings.current.value.channel, releases.releases()) } + offer?.let { UpdateState.Available(it) } ?: UpdateState.UpToDate + } catch (_: IOException) { + if (quiet) before else UpdateState.CheckFailed + } + } + } + + private suspend fun installing(offer: UpdateOffer): UpdateState { + mutableState.value = UpdateState.Downloading(offer, 0f) + try { + val manifest = releases.manifest(offer.target) + if (manifest.packageName != installer.packageName || manifest.versionCode <= installed.versionCode) { + return UpdateState.Failed(offer, UpdateFailure.NotThisApp) + } + download.download(offer.target.apkUrl, manifest.sha256, apkFile) { progress -> + mutableState.value = UpdateState.Downloading(offer, progress) + } + } catch (_: UpdateVerificationException) { + return UpdateState.Failed(offer, UpdateFailure.Checksum) + } catch (_: IOException) { + return UpdateState.Failed(offer, UpdateFailure.Download) + } + mutableState.value = UpdateState.Installing(offer) + val result = installer.install(apkFile) + apkFile.delete() + return when (result) { + InstallResult.Success -> UpdateState.UpToDate + InstallResult.Cancelled -> UpdateState.Available(offer) + InstallResult.Conflict -> UpdateState.Failed(offer, UpdateFailure.Signature) + is InstallResult.Failed -> UpdateState.Failed(offer, UpdateFailure.Refused) + } + } + + private companion object { + const val RECHECK_MILLIS = 6 * 60 * 60 * 1000L + } +} diff --git a/feature/update/src/test/java/video/typetype/tv/ui/update/UpdateControllerTest.kt b/feature/update/src/test/java/video/typetype/tv/ui/update/UpdateControllerTest.kt new file mode 100644 index 0000000..54bccb9 --- /dev/null +++ b/feature/update/src/test/java/video/typetype/tv/ui/update/UpdateControllerTest.kt @@ -0,0 +1,198 @@ +package video.typetype.tv.ui.update + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import video.typetype.tv.data.device.DeviceStore +import video.typetype.tv.data.device.UpdateSettings +import video.typetype.tv.data.update.ApkDownload +import video.typetype.tv.data.update.AppRelease +import video.typetype.tv.data.update.AppVersion +import video.typetype.tv.data.update.ReleaseSource +import video.typetype.tv.data.update.UpdateManifest +import video.typetype.tv.data.update.UpdateVerificationException +import java.io.File +import java.io.IOException + +class UpdateControllerTest { + @get:Rule + val folder = TemporaryFolder() + + private class MemoryStore : DeviceStore { + val values = mutableMapOf() + + override fun flag(key: String, fallback: Boolean) = values[key] as? Boolean ?: fallback + + override fun putFlag(key: String, value: Boolean) { + values[key] = value + } + + override fun text(key: String, fallback: String) = values[key] as? String ?: fallback + + override fun putText(key: String, value: String) { + values[key] = value + } + } + + private fun release(tag: String) = AppRelease(requireNotNull(AppVersion.parse(tag)), tag, "", "apk/$tag", "manifest/$tag") + + private inner class Source : ReleaseSource { + var listed = listOf(release("v0.1.0"), release("v0.1.1"), release("v0.2.0-beta.1")) + var failing = false + var checks = 0 + var manifest = UpdateManifest(PACKAGE, "0.1.1", 10_199, "app.apk", "sha") + + override suspend fun releases(): List { + checks++ + if (failing) throw IOException("offline") + return listed + } + + override suspend fun manifest(release: AppRelease) = manifest + } + + private class Installer(var result: InstallResult = InstallResult.Success) : UpdateInstaller { + override val packageName = PACKAGE + val installed = mutableListOf() + + override suspend fun install(apk: File): InstallResult { + installed += apk.readText() + return result + } + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + private val source = Source() + private val installer = Installer() + private val settings = UpdateSettings(MemoryStore()) + private var now = 0L + private val downloads = mutableListOf() + private var download = ApkDownload { url, sha256, target, onProgress -> + downloads += "$url $sha256" + onProgress(0.5f) + target.writeText(url) + onProgress(1f) + } + + private fun controller(automatic: Boolean = true) = UpdateController( + releases = source, + download = { url, sha, target, progress -> download.download(url, sha, target, progress) }, + installer = installer, + settings = settings, + installed = InstalledApp(PACKAGE, "0.1.0", 10_099), + apkFile = folder.root.resolve("update.apk"), + scope = scope, + automatic = automatic, + clock = { now }, + ) + + @After + fun stop() = scope.cancel() + + @Test + fun anUpdateFoundAtStartupWaitsInTheTopBarWithoutOpeningThePanel() { + val updates = controller() + updates.checkIfDue() + assertEquals("v0.1.1", updates.state.value.offer?.target?.tag) + assertFalse(updates.panelOpen.value) + } + + @Test + fun theStartupCheckRunsAgainOnlyAfterSomeHours() { + val updates = controller() + updates.checkIfDue() + now += 60 * 60 * 1000L + updates.checkIfDue() + assertEquals(1, source.checks) + now += 6 * 60 * 60 * 1000L + updates.checkIfDue() + assertEquals(2, source.checks) + } + + @Test + fun noCheckRunsOnItsOwnForADebugBuildOrWhenTurnedOff() { + controller(automatic = false).checkIfDue() + settings.update("update-on-start", "false") + controller().checkIfDue() + assertEquals(0, source.checks) + } + + @Test + fun aQuietCheckThatFailsKeepsWhatWasKnownWhileAskedOneSaysSo() { + val updates = controller() + updates.checkIfDue() + source.failing = true + now += 7 * 60 * 60 * 1000L + updates.checkIfDue() + assertTrue(updates.state.value is UpdateState.Available) + updates.checkNow() + assertTrue(updates.panelOpen.value) + assertEquals(UpdateState.CheckFailed, updates.state.value) + } + + @Test + fun theBetaChannelIsCheckedAsSoonAsItIsChosen() { + val updates = controller() + updates.checkIfDue() + settings.update("update-channel", "beta") + assertEquals("v0.2.0-beta.1", updates.state.value.offer?.target?.tag) + } + + @Test + fun theNewestVersionIsUpToDate() { + source.listed = listOf(release("v0.1.0")) + val updates = controller() + updates.checkNow() + assertEquals(UpdateState.UpToDate, updates.state.value) + } + + @Test + fun anInstallDownloadsTheCheckedApkAndHandsItToTheSystem() { + val updates = controller() + updates.checkNow() + updates.install() + assertEquals(listOf("apk/v0.1.1 sha"), downloads) + assertEquals(listOf("apk/v0.1.1"), installer.installed) + assertEquals(UpdateState.UpToDate, updates.state.value) + assertFalse(folder.root.resolve("update.apk").exists()) + } + + @Test + fun decliningTheSystemConfirmationLeavesTheUpdateOffered() { + installer.result = InstallResult.Cancelled + val updates = controller() + updates.checkNow() + updates.install() + assertTrue(updates.state.value is UpdateState.Available) + } + + @Test + fun eachFailureIsNamed() { + val updates = controller() + updates.checkNow() + installer.result = InstallResult.Conflict + updates.install() + assertEquals(UpdateFailure.Signature, (updates.state.value as UpdateState.Failed).reason) + download = ApkDownload { _, _, _, _ -> throw UpdateVerificationException() } + updates.install() + assertEquals(UpdateFailure.Checksum, (updates.state.value as UpdateState.Failed).reason) + download = ApkDownload { _, _, _, _ -> throw IOException("cut") } + updates.install() + assertEquals(UpdateFailure.Download, (updates.state.value as UpdateState.Failed).reason) + source.manifest = source.manifest.copy(packageName = "other.app") + updates.install() + assertEquals(UpdateFailure.NotThisApp, (updates.state.value as UpdateState.Failed).reason) + } + + private companion object { + const val PACKAGE = "video.typetype.tv" + } +} From 3594cdaa471ce83f1b904c62893880d14d42148a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:13:18 +0200 Subject: [PATCH 6/7] feat: offer app updates from the top bar and the settings --- app/build.gradle.kts | 1 + .../java/video/typetype/tv/AppContainer.kt | 28 +++ .../java/video/typetype/tv/ui/AppEntry.kt | 3 + .../main/java/video/typetype/tv/ui/AppRoot.kt | 9 + .../resources/src/main/res/values/strings.xml | 23 +++ .../video/typetype/tv/ui/components/TopNav.kt | 23 ++- .../typetype/tv/ui/update/ReleaseNotesList.kt | 71 ++++++++ .../video/typetype/tv/ui/update/UpdateHost.kt | 29 +++ .../typetype/tv/ui/update/UpdatePanel.kt | 172 ++++++++++++++++++ 9 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 feature/update/src/main/java/video/typetype/tv/ui/update/ReleaseNotesList.kt create mode 100644 feature/update/src/main/java/video/typetype/tv/ui/update/UpdateHost.kt create mode 100644 feature/update/src/main/java/video/typetype/tv/ui/update/UpdatePanel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 510b4d5..e82d3b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { implementation(project(":feature:collections")) implementation(project(":feature:detail")) implementation(project(":feature:player")) + implementation(project(":feature:update")) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.datasource.okhttp) diff --git a/app/src/main/java/video/typetype/tv/AppContainer.kt b/app/src/main/java/video/typetype/tv/AppContainer.kt index 0a35c2c..bcedacb 100644 --- a/app/src/main/java/video/typetype/tv/AppContainer.kt +++ b/app/src/main/java/video/typetype/tv/AppContainer.kt @@ -1,7 +1,12 @@ package video.typetype.tv import android.content.Context +import android.content.pm.ApplicationInfo +import androidx.core.content.pm.PackageInfoCompat import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import video.typetype.tv.data.ContentRepository import video.typetype.tv.data.PresenceReport import video.typetype.tv.data.device.PerformanceSettings @@ -10,6 +15,7 @@ import video.typetype.tv.data.device.UpdateSettings import video.typetype.tv.data.dto.UserProfileDto import video.typetype.tv.data.net.AndroidConnectivity import video.typetype.tv.data.net.Connectivity +import video.typetype.tv.data.net.HttpClients import video.typetype.tv.data.net.LazySecretStore import video.typetype.tv.data.net.TinkSecretStore import video.typetype.tv.data.remote.AndroidTextProvider @@ -21,12 +27,18 @@ import video.typetype.tv.data.session.Backend import video.typetype.tv.data.session.DeviceProfileStore import video.typetype.tv.data.session.SessionManager import video.typetype.tv.data.session.StreamAccess +import video.typetype.tv.data.update.ReleaseFeed +import video.typetype.tv.data.update.UpdateDownloader import video.typetype.tv.playback.CodecSupport import video.typetype.tv.playback.DecoderFailures import video.typetype.tv.playback.DeviceCodecSupport import video.typetype.tv.playback.LearnedCodecSupport import video.typetype.tv.playback.PlaybackProvider import video.typetype.tv.playback.RoutingPlaybackProvider +import video.typetype.tv.ui.update.InstalledApp +import video.typetype.tv.ui.update.SessionUpdateInstaller +import video.typetype.tv.ui.update.UpdateController +import java.io.File class AppContainer(context: Context) { private val appContext = context.applicationContext @@ -93,6 +105,22 @@ class AppContainer(context: Context) { @androidx.annotation.OptIn(UnstableApi::class) private fun deviceCodecs(): CodecSupport = LearnedCodecSupport(DeviceCodecSupport(appContext), decoderFailures) + val updates by lazy { + val info = appContext.packageManager.getPackageInfo(appContext.packageName, 0) + val http = HttpClients.updates() + val feed = ReleaseFeed(http) + UpdateController( + releases = feed, + download = UpdateDownloader(http), + installer = SessionUpdateInstaller(appContext), + settings = updateSettings, + installed = InstalledApp(appContext.packageName, info.versionName.orEmpty(), PackageInfoCompat.getLongVersionCode(info)), + apkFile = File(appContext.cacheDir, "update/TypeType-TV.apk"), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + automatic = appContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0, + ) + } + val decoderFailures by lazy { DecoderFailures(PreferencesDeviceStore(appContext, PreferencesDeviceStore.DECODERS)) } } diff --git a/app/src/main/java/video/typetype/tv/ui/AppEntry.kt b/app/src/main/java/video/typetype/tv/ui/AppEntry.kt index fd124bc..816d92f 100644 --- a/app/src/main/java/video/typetype/tv/ui/AppEntry.kt +++ b/app/src/main/java/video/typetype/tv/ui/AppEntry.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import kotlinx.coroutines.launch import video.typetype.tv.AppContainer +import video.typetype.tv.data.device.UpdateSettings import video.typetype.tv.data.session.SessionState import video.typetype.tv.ui.auth.LoginScreen import video.typetype.tv.ui.auth.ReconnectScreen @@ -144,10 +145,12 @@ private fun AppState(container: AppContainer, state: SessionState, scope: Corout "forget-certificate" -> session.forgetCertificate() "mark-notifications-read" -> tryLoad({}) { repository.markNotificationsRead() } "forget-decoders" -> container.decoderFailures.forget() + UpdateSettings.CHECK_NOW -> container.updates.checkNow() } } }, performance = performance, + updates = container.updates, ) } } diff --git a/app/src/main/java/video/typetype/tv/ui/AppRoot.kt b/app/src/main/java/video/typetype/tv/ui/AppRoot.kt index 49c9bf3..cf763ce 100644 --- a/app/src/main/java/video/typetype/tv/ui/AppRoot.kt +++ b/app/src/main/java/video/typetype/tv/ui/AppRoot.kt @@ -44,6 +44,10 @@ import video.typetype.tv.navigation.topTab import video.typetype.tv.theme.Dimens import video.typetype.tv.theme.Palette import video.typetype.tv.ui.components.TopNav +import video.typetype.tv.ui.update.UpdateController +import video.typetype.tv.ui.update.UpdateHost +import video.typetype.tv.ui.update.offer +import androidx.compose.runtime.collectAsState import video.typetype.tv.ui.home.HomeScreen import video.typetype.tv.ui.channel.ChannelScreen import video.typetype.tv.ui.playlist.CollectionKind @@ -85,8 +89,10 @@ fun AppRoot( account: AccountBadge, onSettingsAction: (String) -> Unit, performance: Performance = Performance(), + updates: UpdateController, ) { val navigator = remember { MockNavigator() } + val updateState by updates.state.collectAsState() val homeSession = remember { HomeSession() } val searchSession = remember { SearchSession() } val librarySession = remember { LibrarySession() } @@ -281,6 +287,8 @@ fun AppRoot( contentEntry = homeSession.heroEntry.takeIf { activeTab == TopTab.Home }, navEntry = navEntry, onNavFocusChange = { navFocused = it }, + updateVersion = updateState.offer?.target?.version?.toString(), + onUpdate = updates::openPanel, ) } } @@ -296,6 +304,7 @@ fun AppRoot( }, ) } + UpdateHost(updates) } } } diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 7fd8aa4..40268b6 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -312,6 +312,29 @@ Beta Check when the app opens Asks GitHub, where TypeType TV is published, when the app opens and every few hours while it stays open. + Update + TypeType TV %1$s is available. OK opens the update. + Looking for updates… + TypeType TV is up to date + Updates could not be checked + TypeType TV %1$s is available + Installed: %1$s · %2$s + What\'s new + GitHub could not be reached. Check the connection, then try again. + Downloading… %1$d %% + Confirm on the screen Android opens. TypeType TV then closes to update; open it again afterwards. + The download stopped. Check the connection, then try again. + The download was damaged and has been deleted. Try again. + This release is not an update of this app. + Android refused the update because this copy of TypeType TV is signed with another key. Uninstall it, then install the new version from GitHub. + Android refused the update. Check that TypeType TV is allowed to install apps, then try again. + Download and install + Later + Hide + Cancel + Close + Try again + Check again Blocked channels Select to unblock this channel Block channel diff --git a/core/ui/src/main/java/video/typetype/tv/ui/components/TopNav.kt b/core/ui/src/main/java/video/typetype/tv/ui/components/TopNav.kt index dfd8376..b87fa9d 100644 --- a/core/ui/src/main/java/video/typetype/tv/ui/components/TopNav.kt +++ b/core/ui/src/main/java/video/typetype/tv/ui/components/TopNav.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.SystemUpdate import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -68,6 +70,8 @@ fun TopNav( contentEntry: FocusRequester? = null, navEntry: FocusRequester? = null, onNavFocusChange: (Boolean) -> Unit = {}, + updateVersion: String? = null, + onUpdate: () -> Unit = {}, ) { Box( modifier = modifier @@ -112,7 +116,24 @@ fun TopNav( ) } } - Avatar(account, animated = LocalPerformance.current.animatedAvatar && LocalRemoteActivity.current.idle, Modifier.align(Alignment.CenterEnd)) + Row( + modifier = Modifier.align(Alignment.CenterEnd), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (updateVersion != null) { + PillButton( + label = stringResource(R.string.update_pill), + icon = Icons.Rounded.SystemUpdate, + onClick = onUpdate, + emphasized = true, + compact = true, + description = stringResource(R.string.update_pill_description, updateVersion), + modifier = Modifier.focusProperties { contentEntry?.let { down = it } }, + ) + } + Avatar(account, animated = LocalPerformance.current.animatedAvatar && LocalRemoteActivity.current.idle) + } } } diff --git a/feature/update/src/main/java/video/typetype/tv/ui/update/ReleaseNotesList.kt b/feature/update/src/main/java/video/typetype/tv/ui/update/ReleaseNotesList.kt new file mode 100644 index 0000000..4f6aabd --- /dev/null +++ b/feature/update/src/main/java/video/typetype/tv/ui/update/ReleaseNotesList.kt @@ -0,0 +1,71 @@ +package video.typetype.tv.ui.update + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import video.typetype.tv.data.update.AppRelease +import video.typetype.tv.data.update.NoteBlock +import video.typetype.tv.data.update.ReleaseNotes +import video.typetype.tv.resources.R +import video.typetype.tv.theme.Palette +import video.typetype.tv.theme.Shapes +import video.typetype.tv.theme.Type +import video.typetype.tv.ui.components.TextLabel + +// The notes of every release the update brings, newest first. Each block takes the focus, so Up and Down scroll +// through notes longer than the panel. +@Composable +internal fun ReleaseNotesList(releases: List, modifier: Modifier = Modifier) { + val parsed = remember(releases) { releases.map { it to ReleaseNotes.parse(it.notes) } } + Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextLabel(stringResource(R.string.update_whats_new).uppercase(), Type.Label, color = { Palette.TextLow }) + parsed.forEach { (release, blocks) -> + NoteLine(release.version.toString(), Type.Title, Palette.AccentSoft) + blocks.forEach { block -> + when (block) { + is NoteBlock.Heading -> NoteLine(block.text, Type.Body, Palette.TextHigh) + is NoteBlock.Item -> NoteLine(block.text, Type.Body, Palette.TextMid, bullet = true) + is NoteBlock.Paragraph -> NoteLine(block.text, Type.Body, Palette.TextMid) + } + } + } + } +} + +@Composable +private fun NoteLine(text: String, style: TextStyle, color: Color, bullet: Boolean = false) { + val source = remember { MutableInteractionSource() } + val focused by source.collectIsFocusedAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .clip(Shapes.Card) + .background(if (focused) Palette.SurfaceRaised else Color.Transparent) + .focusable(interactionSource = source) + .semantics { contentDescription = text } + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (bullet) TextLabel("•", style, color = { color }) + TextLabel(text, style, color = { color }) + } +} diff --git a/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateHost.kt b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateHost.kt new file mode 100644 index 0000000..7309d4c --- /dev/null +++ b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdateHost.kt @@ -0,0 +1,29 @@ +package video.typetype.tv.ui.update + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.LifecycleResumeEffect + +// Checks again each time the app comes back to the front, and shows the update panel while it is open. +@Composable +fun UpdateHost(controller: UpdateController) { + LifecycleResumeEffect(controller) { + controller.checkIfDue() + onPauseOrDispose { } + } + val open by controller.panelOpen.collectAsState() + if (!open) return + val state by controller.state.collectAsState() + val preferences by controller.preferences.collectAsState() + val actions = remember(controller) { + UpdateActions( + onInstall = controller::install, + onCancel = controller::cancel, + onCheck = controller::checkNow, + onClose = controller::closePanel, + ) + } + UpdatePanel(state, controller.installed, preferences.channel, actions) +} diff --git a/feature/update/src/main/java/video/typetype/tv/ui/update/UpdatePanel.kt b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdatePanel.kt new file mode 100644 index 0000000..d39f069 --- /dev/null +++ b/feature/update/src/main/java/video/typetype/tv/ui/update/UpdatePanel.kt @@ -0,0 +1,172 @@ +package video.typetype.tv.ui.update + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.SystemUpdate +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import video.typetype.tv.data.update.UpdateChannel +import video.typetype.tv.resources.R +import video.typetype.tv.theme.Palette +import video.typetype.tv.theme.Shapes +import video.typetype.tv.theme.Type +import video.typetype.tv.ui.components.Glyph +import video.typetype.tv.ui.components.PillButton +import video.typetype.tv.ui.components.TextLabel +import video.typetype.tv.ui.components.TvOverlay +import video.typetype.tv.ui.util.requestFocusWhenReady + +internal class UpdateActions( + val onInstall: () -> Unit, + val onCancel: () -> Unit, + val onCheck: () -> Unit, + val onClose: () -> Unit, +) + +private class PanelButton(val label: Int, val icon: ImageVector, val onClick: () -> Unit) + +// The update as one panel, like the update dialog of Morphe Manager: what is installed, what is offered and what it +// brings, then one row of buttons that follows the download and the installation. Back hides it; a download goes on. +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun UpdatePanel(state: UpdateState, installed: InstalledApp, channel: UpdateChannel, actions: UpdateActions) { + val primary = remember { FocusRequester() } + LaunchedEffect(state::class) { primary.requestFocusWhenReady() } + TvOverlay(onClose = actions.onClose) { + Column( + modifier = Modifier + .width(PANEL_WIDTH) + .clip(Shapes.Panel) + .background(Palette.Surface) + .padding(28.dp) + .focusProperties { exit = { FocusRequester.Cancel } } + .focusGroup(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Header(state, installed, channel) + state.offer?.let { offer -> + ReleaseNotesList(offer.releases, Modifier.heightIn(max = NOTES_MAX_HEIGHT)) + } + message(state)?.let { TextLabel(it, Type.Body, color = { Palette.TextMid }) } + if (state is UpdateState.Downloading) ProgressBar(state.progress) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + buttons(state, actions).forEachIndexed { index, button -> + PillButton( + label = stringResource(button.label), + icon = button.icon, + onClick = button.onClick, + emphasized = index == 0, + modifier = if (index == 0) Modifier.focusRequester(primary) else Modifier, + ) + } + } + } + } +} + +@Composable +private fun Header(state: UpdateState, installed: InstalledApp, channel: UpdateChannel) { + val title = when (state) { + UpdateState.Idle, UpdateState.Checking -> stringResource(R.string.update_title_checking) + UpdateState.UpToDate -> stringResource(R.string.update_title_up_to_date) + UpdateState.CheckFailed -> stringResource(R.string.update_title_check_failed) + else -> stringResource(R.string.update_title_available, state.offer?.target?.version.toString()) + } + val channelName = stringResource( + if (channel == UpdateChannel.Beta) R.string.settings_update_channel_beta else R.string.settings_update_channel_stable, + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(52.dp).clip(CircleShape).background(Palette.AccentDeep), + contentAlignment = Alignment.Center, + ) { + Glyph(Icons.Rounded.SystemUpdate, Palette.AccentSoft, 28) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextLabel(title, Type.Headline, maxLines = 2) + TextLabel( + stringResource(R.string.update_installed, installed.versionName, channelName), + Type.Meta, + color = { Palette.TextMid }, + ) + } + } +} + +@Composable +private fun message(state: UpdateState): String? = when (state) { + UpdateState.CheckFailed -> stringResource(R.string.update_check_failed) + is UpdateState.Downloading -> stringResource(R.string.update_downloading, (state.progress * 100).toInt()) + is UpdateState.Installing -> stringResource(R.string.update_installing) + is UpdateState.Failed -> stringResource( + when (state.reason) { + UpdateFailure.Download -> R.string.update_failed_download + UpdateFailure.Checksum -> R.string.update_failed_checksum + UpdateFailure.NotThisApp -> R.string.update_failed_not_this_app + UpdateFailure.Signature -> R.string.update_failed_signature + UpdateFailure.Refused -> R.string.update_failed_refused + }, + ) + else -> null +} + +private fun buttons(state: UpdateState, actions: UpdateActions): List { + val close = PanelButton(R.string.update_close, Icons.Rounded.Close, actions.onClose) + return when (state) { + UpdateState.Idle, UpdateState.Checking -> listOf(close) + UpdateState.UpToDate -> listOf(close, PanelButton(R.string.update_check_again, Icons.Rounded.Refresh, actions.onCheck)) + UpdateState.CheckFailed -> listOf(PanelButton(R.string.update_try_again, Icons.Rounded.Refresh, actions.onCheck), close) + is UpdateState.Available -> listOf( + PanelButton(R.string.update_install, Icons.Rounded.SystemUpdate, actions.onInstall), + PanelButton(R.string.update_later, Icons.Rounded.Close, actions.onClose), + ) + is UpdateState.Downloading, is UpdateState.Installing -> listOf( + PanelButton(R.string.update_hide, Icons.Rounded.VisibilityOff, actions.onClose), + PanelButton(R.string.update_cancel, Icons.Rounded.Close, actions.onCancel), + ) + is UpdateState.Failed -> listOf(PanelButton(R.string.update_try_again, Icons.Rounded.Refresh, actions.onInstall), close) + } +} + +@Composable +private fun ProgressBar(progress: Float) { + Box( + Modifier + .fillMaxWidth() + .height(6.dp) + .clip(CircleShape) + .background(Palette.Glass) + .drawBehind { drawRect(Palette.Accent, size = Size(size.width * progress.coerceIn(0f, 1f), size.height)) }, + ) +} + +private val PANEL_WIDTH = 760.dp +private val NOTES_MAX_HEIGHT = 300.dp From 3adf76655caf50f4ef97ff96598de0a87e267880 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:18:09 +0200 Subject: [PATCH 7/7] chore: release 0.2.0-beta.1 --- app/build.gradle.kts | 2 +- release-notes/v0.2.0-beta.1.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 release-notes/v0.2.0-beta.1.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e82d3b6..a9ae1bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } // The release workflow reads this line: "x.y.z-beta.n" is published from the beta branch, "x.y.z" from main. -val appVersion = "0.1.1-beta.1" +val appVersion = "0.2.0-beta.1" // The version code follows from the name, so each release installs over the one before, and a beta sits under the // stable release that ends it: 0.3.0-beta.2 is 30002 and 0.3.0 is 30099. diff --git a/release-notes/v0.2.0-beta.1.md b/release-notes/v0.2.0-beta.1.md new file mode 100644 index 0000000..98bb2af --- /dev/null +++ b/release-notes/v0.2.0-beta.1.md @@ -0,0 +1,12 @@ +TypeType TV now updates itself. + +- When a new version is published, an Update button appears in the top bar. It shows what the new version + brings, then downloads it, checks its checksum, and hands it to the Android installer. +- Settings > Updates checks right away, switches between the stable and beta channels, and turns off the check + made when the app opens. +- The check asks GitHub, where TypeType TV is published, when the app opens and every few hours while it stays open. + +Versions before this one cannot update themselves: install this one by hand once, and the next ones are offered +in the app. + +Download `TypeType-TV-v0.2.0-beta.1.apk` below; the `.sha256` file lets you check the download.