Implement verified household TV updates and release tooling
This commit is contained in:
parent
152dac97c0
commit
d171aaae9e
29 changed files with 2485 additions and 99 deletions
|
|
@ -56,6 +56,10 @@ val gitDescribe =
|
|||
.standardOutput.asText
|
||||
.getOrElse("v0.0.0")
|
||||
|
||||
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||
apply(from = "household-version.gradle.kts")
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||
|
|
@ -228,6 +232,13 @@ configure<ApplicationExtension> {
|
|||
|
||||
androidComponents {
|
||||
onVariants(selector().all()) { variant ->
|
||||
if (variant.name == "defaultOptimized") {
|
||||
variant.outputs.forEach {
|
||||
it.versionName.set(tvVersion)
|
||||
// The sentinel is configuration-only: optimized tasks require the explicit input above.
|
||||
it.versionCode.set(tvVersionCode.map(String::toInt).orElse(1))
|
||||
}
|
||||
}
|
||||
variant.outputs
|
||||
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
||||
.forEach { output ->
|
||||
|
|
|
|||
18
app/household-version.gradle.kts
Normal file
18
app/household-version.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Household assembly inputs remain mandatory even through aggregate Gradle tasks.
|
||||
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||
|
||||
fun requireHouseholdVersion() {
|
||||
require(tvVersionCode.isPresent) { "Household builds require -PtvVersionCode=<monotonic code>" }
|
||||
require(tvVersionCode.get().toInt() in 1..2100000000) { "Invalid household versionCode" }
|
||||
require(Regex("""\d+\.\d+\.\d+""").matches(tvVersion.get())) { "Household versionName must be plain major.minor.patch" }
|
||||
}
|
||||
|
||||
if (gradle.startParameter.taskNames.any { it.contains("Optimized", ignoreCase = true) }) {
|
||||
requireHouseholdVersion()
|
||||
}
|
||||
tasks.configureEach {
|
||||
if (name.contains("DefaultOptimized", ignoreCase = true)) {
|
||||
doFirst { requireHouseholdVersion() }
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,13 @@
|
|||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.INSTALL_PACKAGE" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
|
|
@ -70,6 +77,13 @@
|
|||
<data android:scheme="wholphin" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name="org.justinvisser.wolfskin.tv.update.UpdateInstallReceiver"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name="org.justinvisser.wolfskin.tv.update.UpdateConfirmationActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
|||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
|
@ -627,7 +628,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
val UpdateUrl =
|
||||
AppStringPreference<AppPreferences>(
|
||||
title = R.string.update_url,
|
||||
defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
|
||||
defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID),
|
||||
getter = { it.updateUrl },
|
||||
setter = { prefs, value ->
|
||||
prefs.update { updateUrl = value }
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.justinvisser.wolfskin.tv.update.AndroidUpdateService
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateTransport
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
|
@ -43,6 +46,7 @@ import java.io.OutputStream
|
|||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import javax.inject.Provider
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
|
@ -55,6 +59,7 @@ class UpdateChecker
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
private val household: Provider<AndroidUpdateService>,
|
||||
) {
|
||||
companion object {
|
||||
const val ASSET_NAME = "Wolfskin"
|
||||
|
|
@ -123,6 +128,10 @@ class UpdateChecker
|
|||
updateUrl: String,
|
||||
showNegativeToast: Boolean = false,
|
||||
) {
|
||||
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||
household.get().controller.check(manual = showNegativeToast)
|
||||
return
|
||||
}
|
||||
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val now = Date()
|
||||
val lastUpdateCheckThreshold =
|
||||
|
|
@ -181,6 +190,12 @@ class UpdateChecker
|
|||
* so this searches the release list instead of fetching a tag.
|
||||
*/
|
||||
suspend fun getRelease(version: Version): Release? {
|
||||
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||
return withContext(WholphinDispatchers.IO) {
|
||||
val releases = UpdateTransport().releasesForLatest(household.get().channelUrl())
|
||||
findReleaseByVersion(releases, version)?.let { parseRelease(it, false) }
|
||||
}
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
|
|
@ -233,6 +248,9 @@ class UpdateChecker
|
|||
release: Release,
|
||||
callback: DownloadCallback,
|
||||
) {
|
||||
check(context.packageName != UpdateChannel.PACKAGE) {
|
||||
"Household updates require the verified update controller"
|
||||
}
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
cleanup()
|
||||
val request =
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import androidx.tv.material3.Text
|
|||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.imageLoader
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -89,6 +90,7 @@ import com.github.damontecres.wholphin.util.DataLoadingState
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -355,7 +357,9 @@ fun PreferencesContent(
|
|||
AppPreference.Update -> {
|
||||
ClickPreference(
|
||||
title =
|
||||
if (release != null && updateAvailable) {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
stringResource(R.string.check_for_updates)
|
||||
} else if (release != null && updateAvailable) {
|
||||
stringResource(R.string.install_update)
|
||||
} else if (!preferences.autoCheckForUpdates && release == null) {
|
||||
stringResource(R.string.check_for_updates)
|
||||
|
|
@ -364,7 +368,9 @@ fun PreferencesContent(
|
|||
},
|
||||
onClick = {
|
||||
if (movementSounds) playOnClickSound(context)
|
||||
if (release != null && updateAvailable) {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||
} else if (release != null && updateAvailable) {
|
||||
release?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.UpdateApp,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.DownloadCallback
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -73,6 +74,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import org.justinvisser.wolfskin.tv.update.ClassicUpdatePage
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateController
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -82,6 +86,7 @@ class UpdateViewModel
|
|||
private val userPreferencesService: UserPreferencesService,
|
||||
val updater: UpdateChecker,
|
||||
val navigationManager: NavigationManager,
|
||||
val householdController: UpdateController,
|
||||
) : ViewModel(),
|
||||
DownloadCallback {
|
||||
private val _state = MutableStateFlow(InstallUpdateState())
|
||||
|
|
@ -93,6 +98,10 @@ class UpdateViewModel
|
|||
val currentVersion = updater.getInstalledVersion()
|
||||
|
||||
fun init() {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
householdController.check(true)
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchIO {
|
||||
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
||||
|
|
@ -166,6 +175,10 @@ fun InstallUpdatePage(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: UpdateViewModel = hiltViewModel(),
|
||||
) {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
ClassicUpdatePage(viewModel.householdController, modifier)
|
||||
return
|
||||
}
|
||||
OneTimeLaunchedEffect { viewModel.init() }
|
||||
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ import java.nio.file.Paths
|
|||
import kotlin.io.path.readText
|
||||
|
||||
class TestUpdateChecker {
|
||||
@Test
|
||||
fun householdChannelMigrationDoesNotChangeCustomOrOldProductionFeeds() {
|
||||
val channel = org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
Assert.assertEquals(channel.LATEST_URL, channel.migrateUrl(channel.PACKAGE, channel.LEGACY_URL))
|
||||
Assert.assertEquals("https://custom.test/releases/latest", channel.migrateUrl(channel.PACKAGE, "https://custom.test/releases/latest"))
|
||||
Assert.assertEquals(channel.LEGACY_URL, channel.migrateUrl("org.justinvisser.wolfskin.tv", channel.LEGACY_URL))
|
||||
Assert.assertEquals(channel.LEGACY_URL, channel.defaultUrl("org.justinvisser.wolfskin.tv"))
|
||||
Assert.assertEquals(channel.LATEST_URL, channel.defaultUrl(channel.PACKAGE))
|
||||
}
|
||||
|
||||
lateinit var releaseJson: JsonObject
|
||||
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,14 @@ class VersionCompareTests {
|
|||
@Test
|
||||
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
||||
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
||||
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
|
||||
Assert.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun householdAvailabilityOrdersAndroidCodesNotDisplayVersions() {
|
||||
Assert.assertTrue(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 65))
|
||||
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(65, 100001))
|
||||
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 100001))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,357 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/** Exercises the production transport, verification store and state machine against HTTP bytes. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HouseholdUpdateControllerTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
private lateinit var server: HttpServer
|
||||
private lateinit var directory: File
|
||||
private lateinit var base: String
|
||||
private val apk = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||
private var code = 100001L
|
||||
private var releaseStatus = 200
|
||||
private var assetStatus = 200
|
||||
private var wireTruncated = false
|
||||
private var metadataTruncated = false
|
||||
private var listStatus = 200
|
||||
private var listBody = "[]"
|
||||
private var invalidJson = false
|
||||
private var badHash = false
|
||||
private var truncated = false
|
||||
private var chunked = false
|
||||
private var unsupported = false
|
||||
private var redirect = false
|
||||
private var redirectTarget: String? = null
|
||||
private val requests = mutableListOf<Map<String, List<String>>>()
|
||||
private val platform = Platform()
|
||||
|
||||
private inner class Platform : UpdatePlatform {
|
||||
var archive = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.1", 100001), setOf(UpdateChannel.SIGNER))
|
||||
var installed = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.0", 65), setOf(UpdateChannel.SIGNER))
|
||||
var permission = true
|
||||
var settingsAvailable = true
|
||||
var installs = 0
|
||||
var outcome = InstallOutcome.Cancelled
|
||||
override fun installed() = installed
|
||||
override fun inspect(file: File) = archive
|
||||
override fun hasInstallPermission() = permission
|
||||
override fun openPermissionSettings() = settingsAvailable
|
||||
override fun install(file: File, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
|
||||
}
|
||||
private val preferences = object : UpdatePreferences {
|
||||
override val autoCheck = MutableStateFlow(true)
|
||||
override suspend fun url() = "$base/releases/latest"
|
||||
override suspend fun enabled() = autoCheck.value
|
||||
override suspend fun setAutoCheck(enabled: Boolean) { autoCheck.value = enabled }
|
||||
}
|
||||
|
||||
@Before fun start() {
|
||||
directory = temporary.newFolder()
|
||||
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
base = "http://127.0.0.1:${server.address.port}"
|
||||
server.createContext("/") { exchange ->
|
||||
synchronized(requests) { requests += exchange.requestHeaders.mapValues { it.value.toList() } }
|
||||
val path = exchange.requestURI.path
|
||||
val status = when (path) {
|
||||
"/releases/latest" -> releaseStatus
|
||||
"/releases" -> listStatus
|
||||
"/apk" -> assetStatus
|
||||
else -> 200
|
||||
}
|
||||
val content = when (path) {
|
||||
"/releases/latest", "/redirected" -> if (invalidJson) "not json" else """{"name":"v1.1.1","body":"Household notes","published_at":"2026-11-07T12:00:00Z","assets":[{"name":"wolfskin-update.json","browser_download_url":"$base/manifest"},{"name":"Wolfskin-armeabi-v7a.apk","browser_download_url":"$base/apk"}]}"""
|
||||
"/manifest" -> UpdateManifest.encode(manifest())
|
||||
"/releases" -> listBody
|
||||
else -> ""
|
||||
}
|
||||
if (redirect && path == "/releases/latest") {
|
||||
exchange.responseHeaders.add("Location", redirectTarget ?: "$base/redirected")
|
||||
exchange.responseHeaders.add("Set-Cookie", "private-session=never-forward")
|
||||
exchange.sendResponseHeaders(302, -1)
|
||||
} else {
|
||||
val bytes = if (path == "/apk") (if (truncated) apk.copyOf(apk.size / 2) else apk) else content.toByteArray()
|
||||
val length = when {
|
||||
chunked && path == "/apk" -> 0L
|
||||
wireTruncated && path == "/apk" -> apk.size.toLong()
|
||||
metadataTruncated && path == "/releases/latest" -> bytes.size + 1L
|
||||
else -> bytes.size.toLong()
|
||||
}
|
||||
exchange.sendResponseHeaders(status, length)
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
exchange.close()
|
||||
}
|
||||
server.start()
|
||||
}
|
||||
@After fun stop() { server.stop(0) }
|
||||
|
||||
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
|
||||
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER,
|
||||
listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(),
|
||||
if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23)))
|
||||
|
||||
private fun controller(scope: kotlinx.coroutines.CoroutineScope) = HouseholdUpdateController(scope, preferences, platform,
|
||||
UpdateTransport(allowLocalTestHttp = true), VerifiedUpdateStore(directory, platform), listOf("armeabi-v7a"), 30)
|
||||
|
||||
@Test fun newerReleaseDownloadsVerifiesAndRestoresWithoutInstalling() = runTest {
|
||||
val controller = controller(this)
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
assertEquals(0, platform.installs)
|
||||
val restored = controller(this); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
|
||||
restored.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
|
||||
assertEquals(1, platform.installs)
|
||||
assertTrue(File(directory, "verified.apk").exists())
|
||||
}
|
||||
|
||||
@Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
|
||||
code = 65
|
||||
val equal = controller(this); equal.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.UpToDate, equal.state.value)
|
||||
code = 64
|
||||
val older = controller(this); older.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.LowerVersionCode, (older.state.value as UpdateUiState.Failed).kind)
|
||||
}
|
||||
|
||||
@Test fun emptyChannelIsDistinctFromMissingOrRefusedEndpoint() = runTest {
|
||||
releaseStatus = 404
|
||||
val empty = controller(this); empty.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.NoReleasePublished, empty.state.value)
|
||||
for (status in listOf(401, 403, 404, 500, 503)) {
|
||||
listStatus = status
|
||||
val failure = controller(this); failure.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.HttpRefused, (failure.state.value as UpdateUiState.Failed).kind)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun refusedLatestNeverBecomesUpToDate() = runTest {
|
||||
for (status in listOf(401, 403, 500, 503)) {
|
||||
releaseStatus = status
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun invalidJsonAndUnavailableAbiAreTypedFailures() = runTest {
|
||||
invalidJson = true
|
||||
val invalid = controller(this); invalid.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.InvalidMetadata, (invalid.state.value as UpdateUiState.Failed).kind)
|
||||
invalidJson = false; unsupported = true
|
||||
val unsupported = controller(this); unsupported.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
|
||||
}
|
||||
|
||||
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
|
||||
for (badChecksum in listOf(true, false)) {
|
||||
badHash = badChecksum; truncated = !badChecksum
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.ChecksumMismatch, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assertFalse(File(directory, "ready.json").exists())
|
||||
assertFalse(File(directory, "download.part").exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun wrongPackageSignerAndArchiveCodeCannotBecomeReady() = runTest {
|
||||
val original = platform.archive
|
||||
for ((archive, expected) in listOf(
|
||||
original.copy(packageId = "other.package") to UpdateFailure.WrongPackage,
|
||||
original.copy(signers = setOf("b".repeat(64))) to UpdateFailure.WrongSigner,
|
||||
original.copy(version = InstalledVersion("1.1.1", 65)) to UpdateFailure.LowerVersionCode,
|
||||
)) {
|
||||
platform.archive = archive
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(expected, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assertFalse(File(directory, "ready.json").exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun chunkedTransferReportsUnknownTotalAndCancelDeletesPartial() = runTest {
|
||||
chunked = true
|
||||
val controller = controller(this)
|
||||
var observedUnknownLength = false
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
controller.state.collect { state ->
|
||||
if (state is UpdateUiState.Downloading && state.bytesDone > 0) {
|
||||
observedUnknownLength = state.bytesTotal == null
|
||||
controller.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
controller.check(true); advanceUntilIdle()
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertTrue(observedUnknownLength)
|
||||
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||
assertFalse(File(directory, "download.part").exists())
|
||||
assertFalse(File(directory, "ready.json").exists())
|
||||
}
|
||||
|
||||
@Test fun redirectRequestsContainNoCredentialsAndRejectUrlUserInfo() = runTest {
|
||||
redirect = true
|
||||
redirectTarget = "http://localhost:${server.address.port}/redirected"
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||
assertTrue(requests.size >= 3)
|
||||
requests.forEach { headers ->
|
||||
assertFalse(headers.keys.any { it.equals("Authorization", true) || it.equals("Cookie", true) ||
|
||||
it.contains("Jellyfin", true) || it.contains("Emby", true) || it.contains("MediaBrowser", true) })
|
||||
}
|
||||
redirectTarget = "http://user:password@127.0.0.1:${server.address.port}/redirected"
|
||||
val rejected = controller(this); rejected.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.InvalidMetadata, (rejected.state.value as UpdateUiState.Failed).kind)
|
||||
}
|
||||
|
||||
@Test fun manualCheckWorksWithAutoOffAndPermissionDenialKeepsReady() = runTest {
|
||||
preferences.autoCheck.value = false
|
||||
val controller = controller(this)
|
||||
controller.check(false); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Idle, controller.state.value)
|
||||
controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
platform.permission = false
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||
controller.onResume(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
|
||||
assertEquals(0, platform.installs)
|
||||
platform.settingsAvailable = false
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.NoInstaller, controller.state.value)
|
||||
}
|
||||
|
||||
@Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
File(directory, "verified.apk").appendText("corrupt")
|
||||
File(directory, "download.part").writeText("incomplete")
|
||||
val recreated = controller(this); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Idle, recreated.state.value)
|
||||
assertFalse(File(directory, "verified.apk").exists())
|
||||
assertFalse(File(directory, "download.part").exists())
|
||||
}
|
||||
|
||||
@Test fun downloadHttpFailureRetriesWithoutFalseSuccess() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
assetStatus = 403
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assetStatus = 200
|
||||
controller.retry(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
}
|
||||
|
||||
@Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
|
||||
truncated = true; wireTruncated = true
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertTrue(controller.state.value is UpdateUiState.Failed)
|
||||
assertFalse(File(directory, "ready.json").exists())
|
||||
assertFalse(File(directory, "download.part").exists())
|
||||
}
|
||||
|
||||
@Test fun exhaustedStorageFailsBeforeDownloading() = runTest {
|
||||
val fullDirectory = object : File(directory.absolutePath) { override fun getUsableSpace(): Long = 0 }
|
||||
val controller = HouseholdUpdateController(this, preferences, platform, UpdateTransport(true),
|
||||
VerifiedUpdateStore(fullDirectory, platform), listOf("armeabi-v7a"), 30)
|
||||
controller.check(true); advanceUntilIdle()
|
||||
val before = requests.size
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.InsufficientStorage, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assertEquals(before, requests.size)
|
||||
}
|
||||
|
||||
@Test fun successCallbackRefreshesPackageManagerInsteadOfClaimingAnUpgrade() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
platform.outcome = InstallOutcome.Success
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(65L, controller.installedVersion.value.versionCode)
|
||||
assertTrue(File(directory, "verified.apk").exists())
|
||||
platform.installed = platform.archive
|
||||
controller.onResume(); advanceUntilIdle()
|
||||
assertEquals(100001L, controller.installedVersion.value.versionCode)
|
||||
assertEquals(UpdateUiState.Idle, controller.state.value)
|
||||
assertFalse(File(directory, "verified.apk").exists())
|
||||
}
|
||||
|
||||
@Test fun successCallbackDeliveredToTheReplacedProcessDoesNotLeaveAFalseHandoff() = runTest {
|
||||
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
|
||||
platform.installed = platform.archive
|
||||
val replaced = controller(this); advanceUntilIdle()
|
||||
replaced.handleInstallOutcome(InstallOutcome.Success); advanceUntilIdle()
|
||||
assertEquals(100001L, replaced.installedVersion.value.versionCode)
|
||||
assertEquals(UpdateUiState.Idle, replaced.state.value)
|
||||
replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Idle, replaced.state.value)
|
||||
assertFalse(File(directory, "verified.apk").exists())
|
||||
}
|
||||
|
||||
@Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
platform.outcome = InstallOutcome.Pending
|
||||
controller.install(); advanceUntilIdle()
|
||||
val before = requests.size
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.HandingOff, controller.state.value)
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(1, platform.installs)
|
||||
assertEquals(before, requests.size)
|
||||
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
|
||||
assertTrue(File(directory, "verified.apk").exists())
|
||||
}
|
||||
|
||||
@Test fun productionTransportRefusesCleartextBeforeRequesting() = runTest {
|
||||
try {
|
||||
UpdateTransport().latest("$base/releases/latest", listOf("armeabi-v7a"), 30)
|
||||
fail("Cleartext should be refused")
|
||||
} catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
|
||||
assertTrue(requests.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun unavailableNetworkIsOfflineNotUpToDate() = runTest {
|
||||
server.stop(0)
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Offline, controller.state.value)
|
||||
}
|
||||
|
||||
@Test fun interruptedFirstCheckIsNetworkFailureNotMissingDownloadStorage() = runTest {
|
||||
directory = File(directory, "not-created")
|
||||
metadataTruncated = true
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.Network, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assertFalse(directory.exists())
|
||||
}
|
||||
|
||||
@Test fun compatibleArm32IsPreferredAndUniversalRespectsMinimumSdk() {
|
||||
val arm32 = manifest().assets.single()
|
||||
val arm64 = arm32.copy(abi = "arm64-v8a", name = "Wolfskin-arm64-v8a.apk")
|
||||
val universal = arm32.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 31)
|
||||
val manifest = manifest().copy(assets = listOf(arm64, arm32, universal))
|
||||
assertEquals(arm32, manifest.selectAsset(listOf("arm64-v8a", "armeabi-v7a"), 30))
|
||||
try { manifest.selectAsset(listOf("unknown"), 30); fail("Minimum SDK must be enforced") }
|
||||
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/** Malformed channel metadata must never become a compatible version comparison. */
|
||||
class UpdateManifestTest {
|
||||
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", 128, "a".repeat(64), 23)
|
||||
private val manifest = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", 100001,
|
||||
"2026-11-07T12:00:00Z", "b".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER, listOf(asset))
|
||||
|
||||
@Test fun malformedManifestFieldsAreRejectedBeforeVersionComparison() {
|
||||
val invalid = listOf(
|
||||
manifest.copy(schemaVersion = 2),
|
||||
manifest.copy(versionName = "1.1.1-opt"),
|
||||
manifest.copy(versionCode = 0),
|
||||
manifest.copy(publishedAt = "yesterday"),
|
||||
manifest.copy(sourceCommit = "main"),
|
||||
manifest.copy(sourceTag = ""),
|
||||
manifest.copy(signerSha256 = "not-a-certificate-fingerprint"),
|
||||
manifest.copy(assets = emptyList()),
|
||||
manifest.copy(assets = listOf(asset.copy(bytes = 0))),
|
||||
manifest.copy(assets = listOf(asset.copy(sha256 = "not-a-checksum"))),
|
||||
manifest.copy(assets = listOf(asset.copy(abi = "arm64-v8a"))),
|
||||
manifest.copy(assets = listOf(asset, asset)),
|
||||
)
|
||||
invalid.forEach { candidate ->
|
||||
try { UpdateManifest.parse(UpdateManifest.encode(candidate)); fail("Invalid manifest was accepted: $candidate") }
|
||||
catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun fallbackRequiresACompatibleStandaloneAsset() {
|
||||
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 35)
|
||||
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
|
||||
assertEquals(universal, parsed.selectAsset(listOf("arm64-v8a"), 35))
|
||||
try { parsed.selectAsset(listOf("arm64-v8a"), 34); fail("Unsupported minimum SDK was accepted") }
|
||||
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||
}
|
||||
|
||||
@Test fun universalDoesNotMakeAnUnsupportedProcessorCompatible() {
|
||||
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk")
|
||||
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
|
||||
try { parsed.selectAsset(listOf("riscv64"), 34); fail("Unsupported processor was accepted") }
|
||||
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** PackageManager inspection and confirmation-required PackageInstaller sessions. */
|
||||
@Singleton
|
||||
class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext private val context: Context) : UpdatePlatform {
|
||||
private val manager = context.packageManager
|
||||
private val sessions = context.getSharedPreferences("update-install-session", Context.MODE_PRIVATE)
|
||||
override fun installed(): ApkIdentity = identity(manager.getPackageInfo(context.packageName, signatureFlags()))
|
||||
override fun inspect(file: File): ApkIdentity? = manager.getPackageArchiveInfo(file.absolutePath, signatureFlags())?.let(::identity)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun signatureFlags(): Int = if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun identity(info: PackageInfo): ApkIdentity {
|
||||
val certificates = if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else info.signatures
|
||||
val hashes = certificates.orEmpty().map {
|
||||
MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).toHexString()
|
||||
}.toSet()
|
||||
val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
|
||||
return ApkIdentity(info.packageName, InstalledVersion(info.versionName.orEmpty(), code), hashes)
|
||||
}
|
||||
|
||||
override fun hasInstallPermission(): Boolean = if (Build.VERSION.SDK_INT >= 26) manager.canRequestPackageInstalls()
|
||||
else Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS, 0) == 1
|
||||
|
||||
override fun openPermissionSettings(): Boolean {
|
||||
val intent = if (Build.VERSION.SDK_INT >= 26) Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
|
||||
else Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
if (intent.resolveActivity(manager) == null) return false
|
||||
return try { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (_: RuntimeException) { false }
|
||||
}
|
||||
|
||||
override fun install(file: File, outcome: (InstallOutcome) -> Unit) {
|
||||
if (context.noBackupFilesDir.usableSpace < file.length()) {
|
||||
outcome(InstallOutcome.InsufficientStorage)
|
||||
return
|
||||
}
|
||||
val installer = manager.packageInstaller
|
||||
val parameters = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setAppPackageName(context.packageName)
|
||||
setSize(file.length())
|
||||
if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
|
||||
}
|
||||
var sessionId: Int? = null
|
||||
try {
|
||||
sessionId = installer.createSession(parameters)
|
||||
check(sessions.edit().putInt("id", sessionId).commit())
|
||||
installer.openSession(sessionId).use { session ->
|
||||
session.openWrite("Wolfskin.apk", 0, file.length()).use { output ->
|
||||
file.inputStream().use { it.copyTo(output) }
|
||||
session.fsync(output)
|
||||
}
|
||||
val intent = Intent(context, UpdateInstallReceiver::class.java).setAction("${context.packageName}.UPDATE_RESULT")
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0
|
||||
val result = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
||||
session.commit(result.intentSender)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
sessionId?.let { runCatching { installer.abandonSession(it) } }
|
||||
outcome(InstallOutcome.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fun ownsSession(intent: Intent): Boolean = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) == sessions.getInt("id", -2)
|
||||
|
||||
fun launchConfirmation(intent: Intent): Boolean {
|
||||
val activity = Intent(context, UpdateConfirmationActivity::class.java).putExtra("confirmation", intent)
|
||||
return try {
|
||||
context.startActivity(activity.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
true
|
||||
} catch (_: RuntimeException) { false }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/** Hilt entry point shared by app startup, Wolfskin UI and classic update controls. */
|
||||
@Singleton
|
||||
class AndroidUpdateService @Inject constructor(
|
||||
@param:ApplicationContext context: Context,
|
||||
@param:IoCoroutineScope scope: CoroutineScope,
|
||||
dataStore: DataStore<AppPreferences>,
|
||||
val platform: AndroidUpdatePlatform,
|
||||
) {
|
||||
private val preferences = object : UpdatePreferences {
|
||||
override val autoCheck = dataStore.data.map { it.autoCheckForUpdates }.stateIn(scope, SharingStarted.Eagerly, true)
|
||||
override suspend fun enabled() = dataStore.data.first().autoCheckForUpdates
|
||||
override suspend fun url(): String {
|
||||
val prefs = dataStore.updateData { current ->
|
||||
val url = UpdateChannel.migrateUrl(context.packageName, current.updateUrl)
|
||||
if (url == current.updateUrl) current else current.update { updateUrl = url }
|
||||
}
|
||||
return prefs.updateUrl
|
||||
}
|
||||
override suspend fun setAutoCheck(enabled: Boolean) {
|
||||
dataStore.updateData { it.update { autoCheckForUpdates = enabled } }
|
||||
}
|
||||
}
|
||||
val controller = HouseholdUpdateController(scope, preferences, platform, UpdateTransport(),
|
||||
VerifiedUpdateStore(File(context.noBackupFilesDir, "updates"), platform), Build.SUPPORTED_ABIS.toList(), Build.VERSION.SDK_INT)
|
||||
suspend fun channelUrl(): String = preferences.url()
|
||||
}
|
||||
|
||||
/** Single process-scoped controller binding for every update entry point. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object UpdateModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUpdateController(service: AndroidUpdateService): UpdateController = service.controller
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
|
||||
/** Trial entry point uses the same verified service, never the legacy download-and-install path. */
|
||||
@Composable
|
||||
fun ClassicUpdatePage(controller: UpdateController, modifier: Modifier = Modifier) {
|
||||
val state by controller.state.collectAsState()
|
||||
val installed by controller.installedVersion.collectAsState()
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var notes by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(controller) { controller.check(true) }
|
||||
DisposableEffect(lifecycle, controller) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) controller.onResume() }
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsRowGap)) {
|
||||
Text("Installed: ${installed.versionName}")
|
||||
when (val current = state) {
|
||||
is UpdateUiState.Available -> {
|
||||
Text("${current.versionName} · ${current.bytes} bytes · ${current.dateText}")
|
||||
Button(onClick = controller::download) { Text("Download") }
|
||||
if (current.notes.isNotBlank()) {
|
||||
Button(onClick = { notes = current.notes }) { Text("Release notes") }
|
||||
}
|
||||
}
|
||||
is UpdateUiState.Downloading -> {
|
||||
Text("${current.bytesDone} / ${current.bytesTotal ?: "—"} bytes")
|
||||
Button(onClick = controller::cancel) { Text("Cancel download") }
|
||||
}
|
||||
is UpdateUiState.Ready, UpdateUiState.InstallCancelled, UpdateUiState.PermissionNeeded, UpdateUiState.NoInstaller -> {
|
||||
Text(when (current) {
|
||||
UpdateUiState.InstallCancelled -> "Installation cancelled; verified download retained"
|
||||
UpdateUiState.PermissionNeeded -> "Allow updates from this app in Android settings"
|
||||
UpdateUiState.NoInstaller -> "Android installer or permission settings unavailable"
|
||||
else -> "Verified update ready"
|
||||
})
|
||||
Button(onClick = controller::install) { Text("Install update") }
|
||||
}
|
||||
is UpdateUiState.Failed -> {
|
||||
Text(current.message)
|
||||
Button(onClick = controller::retry) { Text("Retry") }
|
||||
}
|
||||
UpdateUiState.HandingOff -> Text("Confirm installation in Android")
|
||||
else -> {
|
||||
Text(when (current) {
|
||||
UpdateUiState.UpToDate -> "Up to date"
|
||||
UpdateUiState.NoReleasePublished -> "No release published"
|
||||
UpdateUiState.Offline -> "No network"
|
||||
UpdateUiState.Checking -> "Checking"
|
||||
else -> "Check for an update"
|
||||
})
|
||||
Button(onClick = { controller.check(true) }) { Text("Check again") }
|
||||
}
|
||||
}
|
||||
}
|
||||
notes?.let { body ->
|
||||
ScrollableDialog(onDismissRequest = { notes = null }) {
|
||||
item { Markdown(content = body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Existing app preferences, not a separate updater preference store. */
|
||||
interface UpdatePreferences {
|
||||
val autoCheck: StateFlow<Boolean>
|
||||
suspend fun url(): String
|
||||
suspend fun enabled(): Boolean
|
||||
suspend fun setAutoCheck(enabled: Boolean)
|
||||
}
|
||||
|
||||
/** Process-owned operations outlive screens; only explicit install() crosses into Android. */
|
||||
class HouseholdUpdateController(
|
||||
private val scope: CoroutineScope,
|
||||
private val preferences: UpdatePreferences,
|
||||
private val platform: UpdatePlatform,
|
||||
private val transport: UpdateTransport,
|
||||
private val store: VerifiedUpdateStore,
|
||||
private val abis: List<String>,
|
||||
private val sdk: Int,
|
||||
) : UpdateController {
|
||||
private val mutableState = MutableStateFlow<UpdateUiState>(UpdateUiState.Idle)
|
||||
private val mutableVersion = MutableStateFlow(platform.installed().version)
|
||||
override val state = mutableState.asStateFlow()
|
||||
override val installedVersion = mutableVersion.asStateFlow()
|
||||
override val autoCheck = preferences.autoCheck
|
||||
private var candidate: UpdateCandidate? = null
|
||||
private var ready: UpdateManifest? = null
|
||||
private var operation: Job? = null
|
||||
private var retryOperation: () -> Unit = { check(true) }
|
||||
private val restored = scope.async {
|
||||
ready = store.restore(abis, sdk)
|
||||
ready?.let { mutableState.value = UpdateUiState.Ready(it.versionName) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun runOperation(action: suspend () -> Unit): Unit {
|
||||
if (operation?.isActive == true) return
|
||||
operation = scope.launch {
|
||||
restored.await()
|
||||
try { action() }
|
||||
catch (error: CancellationException) { throw error }
|
||||
catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message) }
|
||||
catch (_: UnknownHostException) { mutableState.value = UpdateUiState.Offline }
|
||||
catch (_: ConnectException) { mutableState.value = UpdateUiState.Offline }
|
||||
catch (error: IOException) {
|
||||
val release = candidate
|
||||
val full = state.value is UpdateUiState.Downloading && release != null &&
|
||||
store.directory.usableSpace < release.asset.bytes
|
||||
mutableState.value = UpdateUiState.Failed(if (full) UpdateFailure.InsufficientStorage else UpdateFailure.Network,
|
||||
if (full) "Not enough storage for the update" else "Update transfer failed; try again")
|
||||
}
|
||||
catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun systemOwnsFlow(current: UpdateUiState): Boolean =
|
||||
current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded
|
||||
|
||||
override fun check(manual: Boolean): Unit = runOperation {
|
||||
if (systemOwnsFlow(state.value)) return@runOperation
|
||||
if (!manual && !preferences.enabled()) return@runOperation
|
||||
ready?.let { manifest ->
|
||||
mutableState.update { if (systemOwnsFlow(it)) it else UpdateUiState.Ready(manifest.versionName) }
|
||||
return@runOperation
|
||||
}
|
||||
retryOperation = { check(true) }
|
||||
mutableState.value = UpdateUiState.Checking
|
||||
val release = transport.latest(preferences.url(), abis, sdk)
|
||||
if (release == null) { mutableState.value = UpdateUiState.NoReleasePublished; return@runOperation }
|
||||
val installed = platform.installed()
|
||||
mutableVersion.value = installed.version
|
||||
if (release.manifest.packageId != installed.packageId) throw UpdateException(UpdateFailure.WrongPackage, "Release belongs to a different application")
|
||||
if (installed.signers != setOf(release.manifest.signerSha256)) throw UpdateException(UpdateFailure.WrongSigner, "Release signer differs from this installation")
|
||||
if (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation")
|
||||
if (release.manifest.versionCode == installed.version.versionCode) {
|
||||
candidate = null
|
||||
mutableState.value = UpdateUiState.UpToDate
|
||||
} else { candidate = release; mutableState.value = available(release) }
|
||||
}
|
||||
|
||||
private fun available(release: UpdateCandidate): UpdateUiState.Available {
|
||||
val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault())
|
||||
.withZone(ZoneId.systemDefault()).format(Instant.parse(release.manifest.publishedAt))
|
||||
return UpdateUiState.Available(release.manifest.versionName, release.asset.bytes, date, release.notes)
|
||||
}
|
||||
|
||||
override fun download(): Unit = runOperation {
|
||||
if (systemOwnsFlow(state.value)) return@runOperation
|
||||
if (ready != null) return@runOperation
|
||||
val release = candidate ?: return@runOperation
|
||||
retryOperation = { download() }
|
||||
store.prepare()
|
||||
if (store.directory.usableSpace < release.asset.bytes) throw UpdateException(UpdateFailure.InsufficientStorage, "Not enough storage for the update")
|
||||
mutableState.value = UpdateUiState.Downloading(0, null)
|
||||
try {
|
||||
transport.download(release, store.partial) { done, total -> mutableState.value = UpdateUiState.Downloading(done, total) }
|
||||
currentCoroutineContext().ensureActive()
|
||||
store.commit(release.manifest, release.asset)
|
||||
currentCoroutineContext().ensureActive()
|
||||
ready = release.manifest
|
||||
mutableState.value = UpdateUiState.Ready(release.manifest.versionName)
|
||||
} catch (error: Exception) {
|
||||
store.clear()
|
||||
throw error
|
||||
} finally { store.partial.delete() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun cancel() {
|
||||
if (systemOwnsFlow(state.value)) return
|
||||
transport.cancel()
|
||||
val previous = operation
|
||||
previous?.cancel()
|
||||
operation = scope.launch {
|
||||
previous?.join()
|
||||
store.partial.delete()
|
||||
mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) }
|
||||
?: candidate?.let { available(it) } ?: UpdateUiState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
override fun install(): Unit = runOperation {
|
||||
if (state.value == UpdateUiState.HandingOff) return@runOperation
|
||||
val manifest = ready ?: return@runOperation
|
||||
retryOperation = { install() }
|
||||
try { store.verify(store.apk, manifest, manifest.selectAsset(abis, sdk)) } catch (error: Exception) {
|
||||
store.clear(); ready = null
|
||||
retryOperation = { if (candidate != null) download() else check(true) }
|
||||
throw error
|
||||
}
|
||||
if (!platform.hasInstallPermission()) {
|
||||
mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller
|
||||
return@runOperation
|
||||
}
|
||||
mutableState.value = UpdateUiState.HandingOff
|
||||
platform.install(store.apk, ::handleInstallOutcome)
|
||||
}
|
||||
|
||||
fun handleInstallOutcome(outcome: InstallOutcome) {
|
||||
scope.launch {
|
||||
restored.await()
|
||||
if (ready == null && outcome != InstallOutcome.Pending) {
|
||||
retryOperation = { check(true) }
|
||||
mutableState.value = UpdateUiState.Idle
|
||||
onResume()
|
||||
return@launch
|
||||
}
|
||||
mutableState.value = when (outcome) {
|
||||
InstallOutcome.Pending -> UpdateUiState.HandingOff
|
||||
InstallOutcome.Cancelled -> UpdateUiState.InstallCancelled
|
||||
InstallOutcome.Unavailable -> UpdateUiState.NoInstaller
|
||||
InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update")
|
||||
InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained")
|
||||
InstallOutcome.Success -> UpdateUiState.HandingOff
|
||||
}
|
||||
if (outcome != InstallOutcome.Pending) onResume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun retry() { retryOperation() }
|
||||
override fun setAutoCheck(enabled: Boolean) { scope.launch { preferences.setAutoCheck(enabled) } }
|
||||
override fun onResume() {
|
||||
scope.launch {
|
||||
operation?.join()
|
||||
restored.await()
|
||||
mutableVersion.value = platform.installed().version
|
||||
val manifest = ready ?: return@launch
|
||||
if (mutableVersion.value.versionCode >= manifest.versionCode) {
|
||||
store.clear(); ready = null; candidate = null; mutableState.value = UpdateUiState.Idle
|
||||
} else if (state.value == UpdateUiState.PermissionNeeded) {
|
||||
// Returning from settings requires another explicit Install press.
|
||||
mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName)
|
||||
else UpdateUiState.InstallCancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
/** Package-scoped channel policy; the old production sandbox retains its original feed. */
|
||||
object UpdateChannel {
|
||||
const val PACKAGE = "org.justinvisser.wolfskin.tv.opt"
|
||||
const val SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
|
||||
const val LEGACY_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest"
|
||||
const val LATEST_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest"
|
||||
fun defaultUrl(packageId: String): String = if (packageId == PACKAGE) LATEST_URL else LEGACY_URL
|
||||
fun migrateUrl(packageId: String, saved: String): String =
|
||||
if (packageId == PACKAGE && (saved == LEGACY_URL || saved.isBlank())) LATEST_URL else saved
|
||||
fun isNewer(candidateCode: Long, installedCode: Long): Boolean = candidateCode > installedCode
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Bundle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Explicit, non-exported callback target accepts only the locally recorded install session. */
|
||||
@AndroidEntryPoint
|
||||
class UpdateInstallReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var service: AndroidUpdateService
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (!service.platform.ownsSession(intent)) return
|
||||
val outcome = when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirmation = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
if (confirmation != null && service.platform.launchConfirmation(confirmation)) InstallOutcome.Pending else InstallOutcome.Unavailable
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Success
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> InstallOutcome.InsufficientStorage
|
||||
else -> InstallOutcome.Failed
|
||||
}
|
||||
service.controller.handleInstallOutcome(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
/** System confirmation is launched for result so cancelling retains the already-verified APK. */
|
||||
@AndroidEntryPoint
|
||||
class UpdateConfirmationActivity : ComponentActivity() {
|
||||
@Inject lateinit var service: AndroidUpdateService
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (savedInstanceState != null) return
|
||||
val confirmation = intent.getParcelableExtra<Intent>("confirmation")
|
||||
if (confirmation == null || confirmation.resolveActivity(packageManager) == null) {
|
||||
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
try { startActivityForResult(confirmation, 1) } catch (_: RuntimeException) {
|
||||
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("System installer uses the activity result contract")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == 1 && resultCode == RESULT_CANCELED) service.controller.handleInstallOutcome(InstallOutcome.Cancelled)
|
||||
service.controller.onResume()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import java.time.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Signed APK metadata published only after the final APKs have been inspected. */
|
||||
@Serializable
|
||||
data class UpdateManifest(
|
||||
val schemaVersion: Int,
|
||||
val packageId: String,
|
||||
val versionName: String,
|
||||
val versionCode: Long,
|
||||
val publishedAt: String,
|
||||
val sourceCommit: String,
|
||||
val sourceTag: String,
|
||||
val signerSha256: String,
|
||||
val assets: List<UpdateAsset>,
|
||||
) {
|
||||
fun selectAsset(abis: List<String>, sdk: Int): UpdateAsset {
|
||||
fun compatible(abi: String) = assets.firstOrNull { it.abi == abi && it.minSdk <= sdk }
|
||||
if ("armeabi-v7a" in abis) compatible("armeabi-v7a")?.let { return it }
|
||||
for (abi in abis) {
|
||||
if (abi != "armeabi-v7a" && abi != "universal") compatible(abi)?.let { return it }
|
||||
}
|
||||
if (abis.any { it != "universal" && it in supportedAbis }) {
|
||||
compatible("universal")?.let { return it }
|
||||
}
|
||||
throw UpdateException(UpdateFailure.UnsupportedAbi, "No compatible APK for this device")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val versionPattern = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
|
||||
private val commitPattern = Regex("[0-9a-f]{40}")
|
||||
private val hashPattern = Regex("[0-9a-f]{64}")
|
||||
private val supportedAbis = setOf("armeabi-v7a", "arm64-v8a", "x86_64", "universal")
|
||||
fun parse(text: String): UpdateManifest {
|
||||
try {
|
||||
val manifest = json.decodeFromString<UpdateManifest>(text)
|
||||
require(manifest.schemaVersion == 1)
|
||||
require(manifest.packageId.isNotBlank())
|
||||
require(versionPattern.matches(manifest.versionName))
|
||||
require(manifest.versionCode in 1..2100000000)
|
||||
Instant.parse(manifest.publishedAt)
|
||||
require(commitPattern.matches(manifest.sourceCommit))
|
||||
require(manifest.sourceTag.isNotBlank())
|
||||
require(isHash(manifest.signerSha256))
|
||||
require(manifest.assets.isNotEmpty())
|
||||
require(manifest.assets.map { it.abi }.distinct().size == manifest.assets.size)
|
||||
require(manifest.assets.map { it.name }.distinct().size == manifest.assets.size)
|
||||
manifest.assets.forEach {
|
||||
require(it.abi in supportedAbis)
|
||||
require(it.name == if (it.abi == "universal") "Wolfskin.apk" else "Wolfskin-${it.abi}.apk")
|
||||
require(it.bytes > 0 && it.minSdk > 0 && isHash(it.sha256))
|
||||
}
|
||||
return manifest
|
||||
} catch (error: Exception) {
|
||||
throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update manifest", error)
|
||||
}
|
||||
}
|
||||
private fun isHash(value: String) = hashPattern.matches(value)
|
||||
fun encode(manifest: UpdateManifest): String = json.encodeToString(manifest)
|
||||
}
|
||||
}
|
||||
|
||||
/** A standalone APK, not a split installation dependency. */
|
||||
@Serializable
|
||||
data class UpdateAsset(val abi: String, val name: String, val bytes: Long, val sha256: String, val minSdk: Int)
|
||||
|
||||
/** Selected immutable release metadata and its anonymous asset URL. */
|
||||
data class UpdateCandidate(val manifest: UpdateManifest, val asset: UpdateAsset, val url: String, val notes: String)
|
||||
|
||||
/** Typed boundary error, never interpreted as an up-to-date result. */
|
||||
class UpdateException(val kind: UpdateFailure, override val message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Call
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
/** Dedicated unauthenticated client; redirects are individually validated before any request. */
|
||||
class UpdateTransport(private val allowLocalTestHttp: Boolean = false) {
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).build()
|
||||
private val activeCall = AtomicReference<Call?>()
|
||||
|
||||
fun cancel() { activeCall.get()?.cancel() }
|
||||
|
||||
private fun validateUrl(value: String): okhttp3.HttpUrl {
|
||||
val url = value.toHttpUrlOrNull()
|
||||
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update URL")
|
||||
val fixture = BuildConfig.DEBUG && allowLocalTestHttp && url.host in listOf("localhost", "127.0.0.1", "::1")
|
||||
if ((!url.isHttps && !fixture) || url.username.isNotEmpty() || url.password.isNotEmpty()) {
|
||||
throw UpdateException(UpdateFailure.InvalidMetadata, "Update URLs must use credential-free HTTPS")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private suspend fun request(value: String): Response {
|
||||
var url = validateUrl(value)
|
||||
repeat(6) {
|
||||
val call = client.newCall(Request.Builder().url(url).get().build())
|
||||
activeCall.set(call)
|
||||
val response = suspendCancellableCoroutine<Response> { continuation ->
|
||||
continuation.invokeOnCancellation { call.cancel() }
|
||||
try {
|
||||
continuation.resume(call.execute()) { _, response, _ -> response.close() }
|
||||
} catch (error: Exception) { continuation.resumeWithException(error) }
|
||||
}
|
||||
if (response.code !in listOf(301, 302, 303, 307, 308)) return response
|
||||
response.use {
|
||||
val location = it.header("Location")
|
||||
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Redirect has no location")
|
||||
url = validateUrl(url.resolve(location)?.toString()
|
||||
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid redirect"))
|
||||
}
|
||||
}
|
||||
throw UpdateException(UpdateFailure.HttpRefused, "Too many update redirects")
|
||||
}
|
||||
|
||||
private suspend fun readJson(url: String): kotlinx.serialization.json.JsonElement = request(url).use {
|
||||
requireSuccess(it)
|
||||
val source = it.body.source()
|
||||
if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) {
|
||||
throw UpdateException(UpdateFailure.InvalidMetadata, "Update metadata is too large")
|
||||
}
|
||||
try { Json.parseToJsonElement(source.readUtf8()) } catch (error: java.io.IOException) { throw error } catch (error: Exception) {
|
||||
throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release JSON", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Installed-release notes share the same anonymous redirect policy as checks and assets. */
|
||||
suspend fun releasesForLatest(url: String, limit: Int = 50): JsonArray {
|
||||
val latest = validateUrl(url)
|
||||
val list = latest.newBuilder()
|
||||
var last = latest.pathSegments.lastIndex
|
||||
while (last > 0 && latest.pathSegments[last].isEmpty()) {
|
||||
list.removePathSegment(last)
|
||||
last--
|
||||
}
|
||||
return readJson(list.removePathSegment(last).setQueryParameter("limit", limit.toString()).build().toString()).jsonArray
|
||||
}
|
||||
|
||||
/** A latest 404 is empty only when the release-list endpoint successfully returns an empty array. */
|
||||
suspend fun latest(url: String, abis: List<String>, sdk: Int): UpdateCandidate? {
|
||||
try {
|
||||
val release = request(url).use {
|
||||
if (it.code == 404) {
|
||||
if (releasesForLatest(url, 1).isEmpty()) return null
|
||||
}
|
||||
requireSuccess(it)
|
||||
val source = it.body.source()
|
||||
if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) {
|
||||
throw UpdateException(UpdateFailure.InvalidMetadata, "Release metadata is too large")
|
||||
}
|
||||
Json.parseToJsonElement(source.readUtf8()).jsonObject
|
||||
}
|
||||
return candidate(release, abis, sdk)
|
||||
} catch (error: UpdateException) { throw error } catch (error: java.io.IOException) { throw error }
|
||||
catch (error: kotlinx.coroutines.CancellationException) { throw error }
|
||||
catch (error: Exception) { throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release metadata", error) }
|
||||
}
|
||||
|
||||
private suspend fun candidate(release: JsonObject, abis: List<String>, sdk: Int): UpdateCandidate {
|
||||
val assets = release.getValue("assets").jsonArray.map { it.jsonObject }
|
||||
val manifestAsset = assets.single { it["name"]?.jsonPrimitive?.content == "wolfskin-update.json" }
|
||||
val manifest = UpdateManifest.parse(readJson(manifestAsset.getValue("browser_download_url").jsonPrimitive.content).toString())
|
||||
val selected = manifest.selectAsset(abis, sdk)
|
||||
val asset = assets.single { it["name"]?.jsonPrimitive?.content == selected.name }
|
||||
return UpdateCandidate(manifest, selected, validateUrl(asset.getValue("browser_download_url").jsonPrimitive.content).toString(),
|
||||
release["body"]?.jsonPrimitive?.contentOrNull.orEmpty())
|
||||
}
|
||||
|
||||
/** A fresh transfer always truncates partials; no unvalidated HTTP range concatenation. */
|
||||
suspend fun download(candidate: UpdateCandidate, file: File, progress: (Long, Long?) -> Unit) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
request(candidate.url).use { response ->
|
||||
requireSuccess(response)
|
||||
val length = response.body.contentLength().takeIf { it >= 0 }
|
||||
if (length != null && length != candidate.asset.bytes) {
|
||||
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK length differs from manifest")
|
||||
}
|
||||
response.body.byteStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var done = 0L
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val count = input.read(buffer)
|
||||
if (count == -1) break
|
||||
done += count
|
||||
if (done > candidate.asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK exceeds manifest length")
|
||||
output.write(buffer, 0, count)
|
||||
progress(done, length)
|
||||
}
|
||||
output.fd.sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireSuccess(response: Response) {
|
||||
if (!response.isSuccessful) throw UpdateException(UpdateFailure.HttpRefused, "Update server refused request (HTTP ${response.code})")
|
||||
}
|
||||
|
||||
companion object { private const val MAX_METADATA_BYTES = 1024L * 1024 }
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Actual APK identity, read independently of channel metadata. */
|
||||
data class ApkIdentity(val packageId: String, val version: InstalledVersion, val signers: Set<String>)
|
||||
|
||||
/** Android boundary replaced by an in-process fixture in controller tests. */
|
||||
interface UpdatePlatform {
|
||||
fun installed(): ApkIdentity
|
||||
fun inspect(file: File): ApkIdentity?
|
||||
fun hasInstallPermission(): Boolean
|
||||
fun openPermissionSettings(): Boolean
|
||||
fun install(file: File, outcome: (InstallOutcome) -> Unit)
|
||||
}
|
||||
|
||||
/** Installer results do not imply the running process has been replaced. */
|
||||
enum class InstallOutcome { Pending, Success, Cancelled, Failed, Unavailable, InsufficientStorage }
|
||||
|
||||
/** Private staging; a metadata commit occurs only after independently verifying the APK. */
|
||||
class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatform) {
|
||||
val partial = File(directory, "download.part")
|
||||
val apk = File(directory, "verified.apk")
|
||||
private val record = File(directory, "ready.json")
|
||||
|
||||
fun prepare() {
|
||||
if (!directory.isDirectory && !directory.mkdirs()) {
|
||||
throw UpdateException(UpdateFailure.InsufficientStorage, "Cannot create private update storage")
|
||||
}
|
||||
}
|
||||
|
||||
fun verify(file: File, manifest: UpdateManifest, asset: UpdateAsset) {
|
||||
if (file.length() != asset.bytes || sha256(file) != asset.sha256) {
|
||||
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK length or checksum does not match")
|
||||
}
|
||||
val identity = platform.inspect(file)
|
||||
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Android could not inspect the APK")
|
||||
val installed = platform.installed()
|
||||
if (identity.packageId != installed.packageId || identity.packageId != manifest.packageId) {
|
||||
throw UpdateException(UpdateFailure.WrongPackage, "APK belongs to a different application")
|
||||
}
|
||||
if (identity.signers != installed.signers || identity.signers != setOf(manifest.signerSha256)) {
|
||||
throw UpdateException(UpdateFailure.WrongSigner, "APK signer does not match this installation")
|
||||
}
|
||||
if (!UpdateChannel.isNewer(identity.version.versionCode, installed.version.versionCode)) {
|
||||
throw UpdateException(UpdateFailure.LowerVersionCode, "APK is not newer than the installed application")
|
||||
}
|
||||
if (identity.version.versionCode != manifest.versionCode || identity.version.versionName != manifest.versionName) {
|
||||
throw UpdateException(UpdateFailure.InvalidMetadata, "APK version differs from manifest")
|
||||
}
|
||||
}
|
||||
|
||||
fun commit(manifest: UpdateManifest, asset: UpdateAsset) {
|
||||
verify(partial, manifest, asset)
|
||||
check(partial.renameTo(apk)) { "Could not commit verified APK" }
|
||||
val temporary = File(directory, "ready.pending")
|
||||
temporary.outputStream().use { it.write(UpdateManifest.encode(manifest).toByteArray()); it.fd.sync() }
|
||||
check(temporary.renameTo(record)) { "Could not commit verified metadata" }
|
||||
}
|
||||
|
||||
/** Re-hash and re-inspect after process death; a marker alone never establishes trust. */
|
||||
fun restore(abis: List<String>, sdk: Int): UpdateManifest? {
|
||||
partial.delete()
|
||||
File(directory, "ready.pending").delete()
|
||||
if (!record.exists()) { apk.delete(); return null }
|
||||
return try {
|
||||
val manifest = UpdateManifest.parse(record.readText())
|
||||
verify(apk, manifest, manifest.selectAsset(abis, sdk))
|
||||
manifest
|
||||
} catch (_: Exception) { clear(); null }
|
||||
}
|
||||
|
||||
fun clear() { record.delete(); apk.delete(); partial.delete() }
|
||||
companion object {
|
||||
fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) { val count = input.read(buffer); if (count < 0) break; digest.update(buffer, 0, count) }
|
||||
}
|
||||
return digest.digest().toHexString()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue