Round 2 candidate 2: merge origin/main
This commit is contained in:
commit
ed103bcb96
74 changed files with 4267 additions and 201 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -68,11 +68,16 @@ import javax.inject.Singleton
|
|||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [DeviceModule::class, AppModule::class],
|
||||
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
|
||||
)
|
||||
object TestModule {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
|
||||
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun deviceInfo(
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
|
|||
}
|
||||
}
|
||||
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
|
|||
val navigation = NavigationManager()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
model.load(server, user)
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
|
|||
val client = session.identity.value!!.client
|
||||
val libraries = LibraryReadFacade(mockk())
|
||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertNull(libraries.audiences)
|
||||
|
|
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
|
|||
session.authenticate()
|
||||
val libraries = LibraryReadFacade(mockk())
|
||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
val identity = session.identity.value!!
|
||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
|
|||
val navigation = NavigationManager()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
|
||||
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class PersonPickerSessionTest {
|
|||
val navigation = remember { NavigationManager() }
|
||||
val model = remember {
|
||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
|
||||
}
|
||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val clients = mutableListOf<ConductarrClient>()
|
||||
try {
|
||||
model.load(server, justin)
|
||||
|
|
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
|
|||
val session = WolfskinSession()
|
||||
val navigation = NavigationManager()
|
||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
|
||||
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
rule.setContent {
|
||||
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class ShellStartupTimingTest {
|
|||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val load = launch { model.load(server, user) }
|
||||
try {
|
||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||
|
|
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val release = java.util.concurrent.CountDownLatch(1)
|
||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,455 @@
|
|||
// 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 manifestSigner = UpdateChannel.SIGNER
|
||||
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 settingsOpens = 0
|
||||
var inspectAction: (() -> Unit)? = null
|
||||
var outcome = InstallOutcome.Cancelled
|
||||
override fun installed() = installed
|
||||
override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive }
|
||||
override fun hasInstallPermission() = permission
|
||||
override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable }
|
||||
override fun install(file: File, asset: UpdateAsset, 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", manifestSigner,
|
||||
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)
|
||||
val metadata = controller.release.value
|
||||
assertEquals(controller.state.value, metadata)
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
assertEquals(metadata, controller.release.value)
|
||||
assertEquals(0, platform.installs)
|
||||
val restored = controller(this); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
|
||||
assertEquals(metadata, restored.release.value)
|
||||
restored.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
|
||||
assertEquals(1, platform.installs)
|
||||
assertTrue(File(directory, "verified.apk").exists())
|
||||
}
|
||||
|
||||
@Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest {
|
||||
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
|
||||
val metadata = old.release.value!!
|
||||
assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText()))
|
||||
File(directory, "ready.json").writeText(UpdateManifest.encode(manifest()))
|
||||
val recreated = controller(this); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value)
|
||||
assertEquals(metadata.copy(notes = ""), recreated.release.value)
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@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 manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest {
|
||||
manifestSigner = "b".repeat(64)
|
||||
val controller = controller(this)
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assertFalse(File(directory, "verified.apk").exists())
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@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 permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
val metadata = controller.release.value
|
||||
platform.permission = false
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(2, platform.settingsOpens)
|
||||
controller.cancel(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
assertEquals(metadata, controller.release.value)
|
||||
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||
assertEquals(3, platform.settingsOpens)
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
platform.outcome = InstallOutcome.Pending
|
||||
controller.install(); advanceUntilIdle()
|
||||
controller.cancel(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
val before = requests.size
|
||||
controller.install(); advanceUntilIdle()
|
||||
controller.check(true); advanceUntilIdle()
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(1, platform.installs)
|
||||
assertEquals(before, requests.size)
|
||||
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(2, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
platform.inspectAction = { controller.cancel() }
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@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 failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest {
|
||||
val controller = controller(this)
|
||||
releaseStatus = 403
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation)
|
||||
releaseStatus = 200
|
||||
controller.retry(); advanceUntilIdle()
|
||||
assetStatus = 403
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation)
|
||||
assetStatus = 200
|
||||
controller.retry(); advanceUntilIdle()
|
||||
File(directory, "verified.apk").writeBytes(ByteArray(apk.size))
|
||||
controller.install(); advanceUntilIdle()
|
||||
val failed = controller.state.value as UpdateUiState.Failed
|
||||
assertEquals(UpdateOperation.Install, failed.operation)
|
||||
assertEquals(UpdateFailure.ChecksumMismatch, failed.kind)
|
||||
assertFalse(File(directory, "ready.json").exists())
|
||||
}
|
||||
|
||||
@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())
|
||||
controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle()
|
||||
assertEquals(1, platform.installs)
|
||||
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())
|
||||
replaced.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.UpToDate, replaced.state.value)
|
||||
}
|
||||
|
||||
@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,108 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
|
||||
/** Behavior boundaries are controller outcomes, not incidental UI wording. */
|
||||
class UpdateBehaviorTest {
|
||||
@Test fun everyOutcomeHasOnlyExplicitUserActions() {
|
||||
val expected = listOf(
|
||||
UpdateUiState.Idle to listOf(UpdateCommand.Check),
|
||||
UpdateUiState.Checking to listOf(UpdateCommand.Back),
|
||||
UpdateUiState.UpToDate to listOf(UpdateCommand.Check),
|
||||
UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check),
|
||||
updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes),
|
||||
UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel),
|
||||
UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||
UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel),
|
||||
UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel),
|
||||
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
|
||||
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
|
||||
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
|
||||
expected.forEach { (state, commands) ->
|
||||
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
|
||||
}
|
||||
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
|
||||
}
|
||||
|
||||
@Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
|
||||
val installed = "1.1.1"
|
||||
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
|
||||
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
|
||||
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
|
||||
assertEquals(presentUpdate(UpdateUiState.Checking, null),
|
||||
presentUpdate(UpdateUiState.Checking, null, installed))
|
||||
}
|
||||
|
||||
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
|
||||
listOf(null, 0L, -1L).forEach { total ->
|
||||
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
||||
assertNull(ui.fraction)
|
||||
}
|
||||
assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
|
||||
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
|
||||
}
|
||||
|
||||
@Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() {
|
||||
val controller = FakeUpdateController(updateReleaseFixture())
|
||||
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||
focus.key(ShellKey.Right)
|
||||
focus.key(ShellKey.Down)
|
||||
assertTrue(focus.autoFocused)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
focus.key(ShellKey.Ok)
|
||||
assertFalse(controller.autoCheck.value)
|
||||
focus.key(ShellKey.Up)
|
||||
assertFalse(focus.autoFocused)
|
||||
assertEquals(1, focus.actionIndex)
|
||||
assertFalse(controller.autoCheck.value)
|
||||
}
|
||||
|
||||
@Test fun notesTrapFocusAndBackRestoresNotesAction() {
|
||||
val controller = FakeUpdateController(updateReleaseFixture())
|
||||
var backs = 0
|
||||
var navs = 0
|
||||
val steps = mutableListOf<Int>()
|
||||
val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add)
|
||||
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||
focus.key(ShellKey.Right); focus.key(ShellKey.Ok)
|
||||
listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key)
|
||||
assertTrue(focus.notesOpen)
|
||||
assertEquals(listOf(-1, 1), steps)
|
||||
assertEquals(0, navs)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
focus.key(ShellKey.Back)
|
||||
assertFalse(focus.notesOpen)
|
||||
assertEquals(1, focus.actionIndex)
|
||||
assertEquals(0, backs)
|
||||
focus.key(ShellKey.Up)
|
||||
assertEquals(1, navs)
|
||||
focus.key(ShellKey.Back)
|
||||
assertEquals(1, backs)
|
||||
}
|
||||
|
||||
@Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() {
|
||||
val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100))
|
||||
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||
fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) }
|
||||
update(controller.state.value)
|
||||
focus.key(ShellKey.Down)
|
||||
update(UpdateUiState.Downloading(90, 100))
|
||||
assertTrue(focus.autoFocused)
|
||||
update(UpdateUiState.Ready("1.1.1"))
|
||||
assertFalse(focus.autoFocused)
|
||||
focus.key(ShellKey.Right)
|
||||
update(UpdateUiState.PermissionNeeded)
|
||||
update(UpdateUiState.InstallCancelled)
|
||||
assertEquals(0, focus.actionIndex)
|
||||
assertFalse(focus.autoFocused)
|
||||
}
|
||||
}
|
||||
|
||||
/** Same release facts as the approved television frames. */
|
||||
fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026",
|
||||
"Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.")
|
||||
|
|
@ -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,34 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/** A replacement or incomplete stream must never reach the session commit boundary. */
|
||||
class UpdateSessionStreamTest {
|
||||
private val original = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", original.size.toLong(),
|
||||
MessageDigest.getInstance("SHA-256").digest(original).toHexString(), 23)
|
||||
|
||||
@Test fun sameLengthReplacementAfterVerificationIsRejected() {
|
||||
val replaced = original.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() }
|
||||
expectMismatch(replaced)
|
||||
}
|
||||
|
||||
@Test fun truncatedAndOversizedSessionStreamsAreRejected() {
|
||||
expectMismatch(original.copyOf(original.size - 1))
|
||||
expectMismatch(original + byteArrayOf(1))
|
||||
}
|
||||
|
||||
private fun expectMismatch(bytes: ByteArray) {
|
||||
try {
|
||||
bytes.inputStream().use { copyVerifiedUpdate(it, ByteArrayOutputStream(), asset) }
|
||||
fail("Changed installation bytes were accepted")
|
||||
} catch (error: UpdateException) {
|
||||
assertEquals(UpdateFailure.ChecksumMismatch, error.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class UpdatesKeysTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("updates")
|
||||
settingsRow = 1
|
||||
}
|
||||
private lateinit var controller: FakeUpdateController
|
||||
private val reduced = mutableStateOf(false)
|
||||
private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File)
|
||||
|
||||
@Before fun fullscreen() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
rule.mainClock.autoAdvance = false
|
||||
}
|
||||
|
||||
private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) {
|
||||
controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture())
|
||||
if (settings) shell.enterDestination("settings")
|
||||
rule.setContent {
|
||||
CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
||||
settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier ->
|
||||
UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
val target = directory ?: return
|
||||
target.mkdirs()
|
||||
rule.waitForIdle()
|
||||
val file = File(target, "$name.png")
|
||||
file.parentFile?.mkdirs()
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
}
|
||||
|
||||
private fun press(keys: List<Key>) {
|
||||
keys.forEach { key ->
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(320)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
|
||||
shell.settingsRow = 0
|
||||
launch(UpdateUiState.Checking, settings = true)
|
||||
repeat(9) { press(listOf(Key.DirectionDown)) }
|
||||
assertEquals(9, shell.settingsRow)
|
||||
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
|
||||
capture("classic")
|
||||
press(listOf(Key.DirectionCenter))
|
||||
assertEquals("classic", shell.handOff)
|
||||
}
|
||||
|
||||
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
|
||||
launch(updateReleaseFixture())
|
||||
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown))
|
||||
assertTrue(shell.contentOverlayVisible)
|
||||
assertFalse(shell.navFocused)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
press(listOf(Key.Back))
|
||||
assertFalse(shell.contentOverlayVisible)
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
press(listOf(Key.Back))
|
||||
assertEquals("settings", shell.route)
|
||||
assertEquals(1, shell.settingsRow)
|
||||
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
|
||||
}
|
||||
|
||||
@Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() {
|
||||
launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" }))
|
||||
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown))
|
||||
val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange]
|
||||
assertTrue(range.value() > 0f)
|
||||
assertFalse(shell.navFocused)
|
||||
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
|
||||
press(listOf(Key.Back))
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
}
|
||||
|
||||
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
|
||||
launch(updateReleaseFixture())
|
||||
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
press(listOf(Key.DirectionUp))
|
||||
assertTrue(shell.navFocused)
|
||||
press(listOf(Key.DirectionDown))
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
}
|
||||
|
||||
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
|
||||
launch(UpdateUiState.UpToDate)
|
||||
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
|
||||
rule.mainClock.advanceTimeBy(320)
|
||||
rule.runOnIdle { controller.show(UpdateUiState.Checking) }
|
||||
rule.mainClock.advanceTimeBy(320)
|
||||
rule.onNodeWithTag("update-progress").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
|
||||
launch(UpdateUiState.Checking)
|
||||
val target = directory
|
||||
if (target != null) {
|
||||
repeat(113) { frame ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
capture("motion/checking-%03d".format(frame))
|
||||
}
|
||||
}
|
||||
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
|
||||
if (target != null) {
|
||||
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
|
||||
}
|
||||
press(listOf(Key.DirectionDown))
|
||||
rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) }
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||
rule.runOnIdle { reduced.value = true }
|
||||
rule.mainClock.advanceTimeBy(96)
|
||||
capture("motion/reduced-000")
|
||||
rule.mainClock.advanceTimeBy(1600)
|
||||
capture("motion/reduced-100")
|
||||
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue