Compare commits
15 commits
main
...
fm/tv-rd2-
| Author | SHA1 | Date | |
|---|---|---|---|
| 79f64f6e18 | |||
| 7bfa431350 | |||
| aa31199b4f | |||
| 2b142af8d1 | |||
| f9bd630ea7 | |||
| e6ddf1d7e5 | |||
| 98dd54e03b | |||
| 9988a193de | |||
| 174e1ae5c9 | |||
| ae613216d0 | |||
| 60b0c307aa | |||
| 12e908389f | |||
| 5046727b2a | |||
| a159c835c5 | |||
| 6d7513bd0e |
107 changed files with 1840 additions and 4463 deletions
12
Makefile
12
Makefile
|
|
@ -4,7 +4,7 @@ WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle
|
||||||
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
|
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
|
||||||
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
|
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
|
||||||
|
|
||||||
.PHONY: build-image apk apk-debug test release-tag tv-release tv-publish tv-tooling-test
|
.PHONY: build-image apk apk-debug test release-tag
|
||||||
build-image:
|
build-image:
|
||||||
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
|
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
|
||||||
|
|
||||||
|
|
@ -19,13 +19,3 @@ test: build-image
|
||||||
|
|
||||||
release-tag:
|
release-tag:
|
||||||
@bash docker/release-tag.sh
|
@bash docker/release-tag.sh
|
||||||
|
|
||||||
TV_RELEASE_DIR ?= /var/tmp/wolfskin-tv-release-$(VERSION)
|
|
||||||
tv-release: build-image
|
|
||||||
@python3 docker/tv_release.py --version "$(VERSION)" --version-code "$(VERSION_CODE)" --output "$(TV_RELEASE_DIR)" $(TV_RELEASE_FLAGS)
|
|
||||||
|
|
||||||
tv-publish:
|
|
||||||
@python3 docker/tv_publish.py --directory "$(TV_RELEASE_DIR)" $(TV_PUBLISH_FLAGS)
|
|
||||||
|
|
||||||
tv-tooling-test:
|
|
||||||
@python3 -m unittest discover -s docker -p 'test_tv_*.py' -v
|
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,6 @@ Wolfskin is an Android TV client for one household. It is released through Forge
|
||||||
|
|
||||||
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
||||||
|
|
||||||
## Wolfskin TV updates
|
|
||||||
|
|
||||||
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
|
|
||||||
|
|
||||||
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state.
|
|
||||||
|
|
||||||
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Wholphin - an OSS Android TV client for Jellyfin
|
# Wholphin - an OSS Android TV client for Jellyfin
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,6 @@ val gitDescribe =
|
||||||
.standardOutput.asText
|
.standardOutput.asText
|
||||||
.getOrElse("v0.0.0")
|
.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 {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||||
|
|
@ -232,13 +228,6 @@ configure<ApplicationExtension> {
|
||||||
|
|
||||||
androidComponents {
|
androidComponents {
|
||||||
onVariants(selector().all()) { variant ->
|
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
|
variant.outputs
|
||||||
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
||||||
.forEach { output ->
|
.forEach { output ->
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
// 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,13 +36,6 @@
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent>
|
</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>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|
@ -77,13 +70,6 @@
|
||||||
<data android:scheme="wholphin" />
|
<data android:scheme="wholphin" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
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.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
@ -628,7 +627,7 @@ sealed interface AppPreference<Pref, T> {
|
||||||
val UpdateUrl =
|
val UpdateUrl =
|
||||||
AppStringPreference<AppPreferences>(
|
AppStringPreference<AppPreferences>(
|
||||||
title = R.string.update_url,
|
title = R.string.update_url,
|
||||||
defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID),
|
defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
|
||||||
getter = { it.updateUrl },
|
getter = { it.updateUrl },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.update { updateUrl = value }
|
prefs.update { updateUrl = value }
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
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 timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -46,7 +43,6 @@ import java.io.OutputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import javax.inject.Provider
|
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
@ -59,7 +55,6 @@ class UpdateChecker
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
private val household: Provider<AndroidUpdateService>,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val ASSET_NAME = "Wolfskin"
|
const val ASSET_NAME = "Wolfskin"
|
||||||
|
|
@ -128,10 +123,6 @@ class UpdateChecker
|
||||||
updateUrl: String,
|
updateUrl: String,
|
||||||
showNegativeToast: Boolean = false,
|
showNegativeToast: Boolean = false,
|
||||||
) {
|
) {
|
||||||
if (context.packageName == UpdateChannel.PACKAGE) {
|
|
||||||
household.get().controller.check(manual = showNegativeToast)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val now = Date()
|
val now = Date()
|
||||||
val lastUpdateCheckThreshold =
|
val lastUpdateCheckThreshold =
|
||||||
|
|
@ -190,12 +181,6 @@ class UpdateChecker
|
||||||
* so this searches the release list instead of fetching a tag.
|
* so this searches the release list instead of fetching a tag.
|
||||||
*/
|
*/
|
||||||
suspend fun getRelease(version: Version): Release? {
|
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 =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -248,9 +233,6 @@ class UpdateChecker
|
||||||
release: Release,
|
release: Release,
|
||||||
callback: DownloadCallback,
|
callback: DownloadCallback,
|
||||||
) {
|
) {
|
||||||
check(context.packageName != UpdateChannel.PACKAGE) {
|
|
||||||
"Household updates require the verified update controller"
|
|
||||||
}
|
|
||||||
withContext(WholphinDispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
cleanup()
|
cleanup()
|
||||||
val request =
|
val request =
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -90,7 +89,6 @@ import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
@ -357,9 +355,7 @@ fun PreferencesContent(
|
||||||
AppPreference.Update -> {
|
AppPreference.Update -> {
|
||||||
ClickPreference(
|
ClickPreference(
|
||||||
title =
|
title =
|
||||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
if (release != null && updateAvailable) {
|
||||||
stringResource(R.string.check_for_updates)
|
|
||||||
} else if (release != null && updateAvailable) {
|
|
||||||
stringResource(R.string.install_update)
|
stringResource(R.string.install_update)
|
||||||
} else if (!preferences.autoCheckForUpdates && release == null) {
|
} else if (!preferences.autoCheckForUpdates && release == null) {
|
||||||
stringResource(R.string.check_for_updates)
|
stringResource(R.string.check_for_updates)
|
||||||
|
|
@ -368,9 +364,7 @@ fun PreferencesContent(
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (movementSounds) playOnClickSound(context)
|
if (movementSounds) playOnClickSound(context)
|
||||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
if (release != null && updateAvailable) {
|
||||||
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
|
||||||
} else if (release != null && updateAvailable) {
|
|
||||||
release?.let {
|
release?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.UpdateApp,
|
Destination.UpdateApp,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.DownloadCallback
|
import com.github.damontecres.wholphin.services.DownloadCallback
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
|
@ -74,9 +73,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -86,7 +82,6 @@ class UpdateViewModel
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
val updater: UpdateChecker,
|
val updater: UpdateChecker,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val householdController: UpdateController,
|
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
DownloadCallback {
|
DownloadCallback {
|
||||||
private val _state = MutableStateFlow(InstallUpdateState())
|
private val _state = MutableStateFlow(InstallUpdateState())
|
||||||
|
|
@ -98,10 +93,6 @@ class UpdateViewModel
|
||||||
val currentVersion = updater.getInstalledVersion()
|
val currentVersion = updater.getInstalledVersion()
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
|
||||||
householdController.check(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
||||||
|
|
@ -175,10 +166,6 @@ fun InstallUpdatePage(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: UpdateViewModel = hiltViewModel(),
|
viewModel: UpdateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
|
||||||
ClassicUpdatePage(viewModel.householdController, modifier)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
OneTimeLaunchedEffect { viewModel.init() }
|
OneTimeLaunchedEffect { viewModel.init() }
|
||||||
|
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,6 @@ class SwitchSeerrViewModel
|
||||||
_state.update { it.copy(loading = LoadingState.Success) }
|
_state.update { it.copy(loading = LoadingState.Success) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Nothing to check so set success
|
|
||||||
_state.update { it.copy(loading = LoadingState.Success) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,6 @@ import java.nio.file.Paths
|
||||||
import kotlin.io.path.readText
|
import kotlin.io.path.readText
|
||||||
|
|
||||||
class TestUpdateChecker {
|
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
|
lateinit var releaseJson: JsonObject
|
||||||
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,7 @@ class VersionCompareTests {
|
||||||
@Test
|
@Test
|
||||||
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
||||||
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
||||||
Assert.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299")))
|
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -68,16 +68,11 @@ import javax.inject.Singleton
|
||||||
@Module
|
@Module
|
||||||
@TestInstallIn(
|
@TestInstallIn(
|
||||||
components = [SingletonComponent::class],
|
components = [SingletonComponent::class],
|
||||||
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
|
replaces = [DeviceModule::class, AppModule::class],
|
||||||
)
|
)
|
||||||
object TestModule {
|
object TestModule {
|
||||||
val testDispatcher = StandardTestDispatcher()
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
|
|
||||||
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun deviceInfo(
|
fun deviceInfo(
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
model.load(server, user)
|
model.load(server, user)
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
val client = session.identity.value!!.client
|
val client = session.identity.value!!.client
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
assertNull(libraries.audiences)
|
assertNull(libraries.audiences)
|
||||||
|
|
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
session.authenticate()
|
session.authenticate()
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
val identity = session.identity.value!!
|
val identity = session.identity.value!!
|
||||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
|
||||||
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
||||||
assertTrue(revoked.item.ratings.isEmpty())
|
assertTrue(revoked.item.ratings.isEmpty())
|
||||||
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
||||||
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
|
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ class ConductarrTitleSurfaceTest {
|
||||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||||
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
||||||
rule.waitUntil(30_000) {
|
rule.waitUntil(30_000) {
|
||||||
|
|
@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest {
|
||||||
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
||||||
}
|
}
|
||||||
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
|
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
|
||||||
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
|
assertFalse(viewModel.title!!.options.any { it.id == "request" })
|
||||||
|
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
|
||||||
} finally { viewModel.viewModelScope.cancel() }
|
} finally { viewModel.viewModelScope.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
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.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Refreshing a loaded Discover title must preserve its real header instead of rebuilding a skeleton. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class DiscoverRefreshMotionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
|
||||||
|
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
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.localArt = true
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true))
|
||||||
|
val person = ShellPerson("shared", "Shared", "S", false)
|
||||||
|
val route = titleRoute("tv:44")
|
||||||
|
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
||||||
|
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
|
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
||||||
|
val refreshLookup = CompletableDeferred<java.util.UUID?>()
|
||||||
|
try {
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
model.title?.let { state ->
|
||||||
|
val bitmaps = listOfNotNull(state.data.item.poster, state.data.item.backdrop).associateWith { art }
|
||||||
|
CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) {
|
||||||
|
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
model.title?.data?.status == TitleReadStatus.Ready && model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-refresh").apply { mkdirs() }
|
||||||
|
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
|
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
|
||||||
|
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
|
||||||
|
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
|
||||||
|
rule.runOnIdle {
|
||||||
|
model.load(route, person, preferences, fixtures = false, force = true)
|
||||||
|
assertEquals("An already-loaded catalogue title must never emit Loading on refresh", TitleReadStatus.Ready, model.title!!.data.status)
|
||||||
|
}
|
||||||
|
repeat(12) { index ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
|
||||||
|
assertFalse("A refresh cannot focus the root navigation", shell.navFocused)
|
||||||
|
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
||||||
|
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
refreshLookup.complete(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
return try {
|
||||||
|
val width = bounds.width.toInt()
|
||||||
|
val height = bounds.height.toInt()
|
||||||
|
bitmap.getPixels(pixels, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height)
|
||||||
|
pixels.sumOf { (android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)).toLong() }
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||||
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
|
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")))
|
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ class PersonPickerSessionTest {
|
||||||
val navigation = remember { NavigationManager() }
|
val navigation = remember { NavigationManager() }
|
||||||
val model = remember {
|
val model = remember {
|
||||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
|
||||||
}
|
}
|
||||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
val clients = mutableListOf<ConductarrClient>()
|
val clients = mutableListOf<ConductarrClient>()
|
||||||
try {
|
try {
|
||||||
model.load(server, justin)
|
model.load(server, justin)
|
||||||
|
|
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
model.load(server, justin)
|
model.load(server, justin)
|
||||||
requireNotNull(model.onForeground(server, justin)).join()
|
requireNotNull(model.onForeground(server, justin)).join()
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
|
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
|
||||||
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
try {
|
try {
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class ShellStartupTimingTest {
|
||||||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
val load = launch { model.load(server, user) }
|
val load = launch { model.load(server, user) }
|
||||||
try {
|
try {
|
||||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||||
|
|
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
val pending = CompletableDeferred<Unit>()
|
val pending = CompletableDeferred<Unit>()
|
||||||
val release = java.util.concurrent.CountDownLatch(1)
|
val release = java.util.concurrent.CountDownLatch(1)
|
||||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
|
@ -69,9 +70,12 @@ class TitleAsyncFocusTest {
|
||||||
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
|
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
|
||||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertTrue(shell.navFocused)
|
assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused)
|
assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused)
|
||||||
|
assertEquals("header", viewModel.title!!.row)
|
||||||
|
assertEquals(0, viewModel.title!!.column)
|
||||||
|
|
||||||
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
|
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
|
|
@ -109,9 +113,12 @@ class TitleAsyncFocusTest {
|
||||||
} }
|
} }
|
||||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertTrue(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertTrue(shell.navFocused)
|
assertFalse("Down cannot enter a pending person skeleton", shell.navFocused)
|
||||||
|
assertEquals("header", viewModel.title!!.row)
|
||||||
|
assertEquals(0, viewModel.title!!.column)
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
|
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
|
||||||
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
|
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
|
||||||
|
|
@ -121,7 +128,7 @@ class TitleAsyncFocusTest {
|
||||||
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
|
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
|
||||||
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
|
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused)
|
assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused)
|
||||||
assertEquals(0, viewModel.person!!.index)
|
assertEquals(0, viewModel.person!!.index)
|
||||||
press(Key.DirectionRight)
|
press(Key.DirectionRight)
|
||||||
assertEquals(1, viewModel.person!!.index)
|
assertEquals(1, viewModel.person!!.index)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
||||||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||||
data.episodes.forEach { item ->
|
data.episodes.forEach { item ->
|
||||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||||
|
|
@ -170,7 +170,10 @@ class TitleCaptureTest {
|
||||||
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
||||||
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
||||||
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
||||||
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
|
@Test fun st047_mediaInfo() = capture(47) { state, shell ->
|
||||||
|
open(state, shell, "menu")
|
||||||
|
state.clickSheet(0)
|
||||||
|
}
|
||||||
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
||||||
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
|
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
|
||||||
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
||||||
|
|
@ -216,4 +219,10 @@ class TitleCaptureTest {
|
||||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||||
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||||
|
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
|
||||||
|
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
|
||||||
|
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
|
||||||
|
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
|
||||||
|
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||||
|
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,9 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performMouseInput
|
import androidx.compose.ui.test.performMouseInput
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.hasClickAction
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -36,7 +34,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
|
||||||
|
|
||||||
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
|
@ -284,24 +281,19 @@ class TitleKeysTest {
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertEquals(expected, state.row)
|
assertEquals(expected, state.row)
|
||||||
}
|
}
|
||||||
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
|
|
||||||
render("Andor")
|
|
||||||
val layouts = mutableListOf<TextLayoutResult>()
|
|
||||||
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
|
|
||||||
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
|
|
||||||
val layout = layouts.single()
|
|
||||||
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
|
|
||||||
assertEquals(1, layout.lineCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
|
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
|
||||||
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
||||||
rule.onNodeWithText("Cast").assertDoesNotExist()
|
rule.onNodeWithText("Cast").assertDoesNotExist()
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertTrue(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
assertEquals("header", state.row)
|
assertEquals("header", state.row)
|
||||||
|
assertEquals(0, state.column)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
assertEquals(listOf(TitleAction.Pop), effects)
|
assertEquals(listOf(TitleAction.Pop), effects)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -315,26 +307,6 @@ class TitleKeysTest {
|
||||||
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
|
|
||||||
render()
|
|
||||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
|
|
||||||
press(Key.DirectionCenter)
|
|
||||||
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
|
|
||||||
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
|
|
||||||
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
|
||||||
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun filmHeaderKeepsItsFixedCssStack() {
|
|
||||||
render()
|
|
||||||
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
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
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Native title interactions keep secondary actions, focus and section geometry intact. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class TitleRound2Test {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val effects = mutableListOf<TitleAction>()
|
||||||
|
private lateinit var state: TitleState
|
||||||
|
private lateinit var shell: ShellState
|
||||||
|
|
||||||
|
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||||
|
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
|
||||||
|
val fixture = fixture()
|
||||||
|
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
|
||||||
|
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
BackHandler { state.back(shell) }
|
||||||
|
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
|
||||||
|
private fun menu() {
|
||||||
|
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
|
||||||
|
render()
|
||||||
|
menu()
|
||||||
|
val opener = state.column
|
||||||
|
press(Key.Back)
|
||||||
|
assertNull(state.sheet)
|
||||||
|
assertEquals(opener, state.column)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||||
|
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
press(Key.Back)
|
||||||
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertNull(state.sheet)
|
||||||
|
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||||
|
assertEquals(opener, state.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
|
||||||
|
render("The Amazing Spider-Man")
|
||||||
|
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
|
||||||
|
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
|
||||||
|
menu()
|
||||||
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
|
||||||
|
render("The Amazing Spider-Man")
|
||||||
|
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
|
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||||
|
val gap = cast.top - actions.bottom
|
||||||
|
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
|
||||||
|
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
|
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(requestedActions.bottom < requestedCast.top)
|
||||||
|
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
|
||||||
|
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
|
||||||
|
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
|
||||||
|
rule.onNodeWithTag("title-progress").assertIsDisplayed()
|
||||||
|
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("seasons", state.row)
|
||||||
|
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() {
|
||||||
|
render("Andor")
|
||||||
|
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||||
|
menu()
|
||||||
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
|
press(Key.Back)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("seasons", state.row)
|
||||||
|
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() {
|
||||||
|
render("episode-episode-1-2025-s1e1", "shared") { data ->
|
||||||
|
data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))),
|
||||||
|
watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))),
|
||||||
|
episodes = data.episodes.distinctBy { it.id })
|
||||||
|
}
|
||||||
|
rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1)
|
||||||
|
rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist()
|
||||||
|
rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
|
||||||
|
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||||
|
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
|
||||||
|
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertEquals(skeleton, ready)
|
||||||
|
assertEquals(448f, ready.width, 0.1f)
|
||||||
|
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
|
||||||
|
rule.runOnIdle { state.focus("episodes", 0) }
|
||||||
|
val episode = state.data.episodes.first()
|
||||||
|
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
|
||||||
|
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
|
||||||
|
render()
|
||||||
|
menu()
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||||
|
rule.onNodeWithText("Add for someone").assertDoesNotExist()
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||||
|
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
|
||||||
|
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
|
||||||
|
for (held in listOf(false, true)) {
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
|
||||||
|
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
|
||||||
|
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
|
||||||
|
render("The Amazing Spider-Man")
|
||||||
|
menu()
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||||
|
assertNull(state.sheet)
|
||||||
|
rule.mainClock.advanceTimeBy(300)
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
|
||||||
|
render()
|
||||||
|
menu()
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
|
||||||
|
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
|
||||||
|
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
|
||||||
|
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
|
||||||
|
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
|
||||||
|
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
|
||||||
|
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||||
|
menu()
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.clickSheet(0) }
|
||||||
|
var waited = 0
|
||||||
|
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
waited += 16
|
||||||
|
}
|
||||||
|
val records = mutableListOf<String>()
|
||||||
|
fun frame(phase: String, index: Int): Pair<Int, Int> {
|
||||||
|
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(path.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||||
|
fun brightness(x: Int, y: Int): Int {
|
||||||
|
val pixel = bitmap.getPixel(x, y)
|
||||||
|
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
val info = List(14) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||||
|
}
|
||||||
|
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
|
||||||
|
rule.runOnIdle { state.back(shell) }
|
||||||
|
repeat(14) { index ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
|
||||||
|
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
|
||||||
|
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||||
|
var waited = 0
|
||||||
|
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
waited += 16
|
||||||
|
}
|
||||||
|
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
val samples = mutableListOf<String>()
|
||||||
|
fun frame(phase: String, index: Int): Int {
|
||||||
|
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
val brightness = try {
|
||||||
|
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
|
||||||
|
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
} finally { image.recycle() }
|
||||||
|
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
|
||||||
|
return brightness
|
||||||
|
}
|
||||||
|
fun assertMonotonic(values: List<Int>) {
|
||||||
|
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
|
||||||
|
val direction = if (values.last() > values.first()) 1 else -1
|
||||||
|
values.zipWithNext().forEach { (before, after) ->
|
||||||
|
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val entering = List(14) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
|
||||||
|
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||||
|
}
|
||||||
|
assertMonotonic(entering)
|
||||||
|
rule.runOnIdle { state.back(shell) }
|
||||||
|
val leaving = mutableListOf<Int>()
|
||||||
|
repeat(9) { index ->
|
||||||
|
rule.waitForIdle()
|
||||||
|
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
}
|
||||||
|
assertMonotonic(leaving)
|
||||||
|
rule.onNodeWithTag("title-menu").assertDoesNotExist()
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,455 +0,0 @@
|
||||||
// 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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
// 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.")
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// 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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -90,7 +90,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||||
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
||||||
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true &&
|
title?.data?.status == TitleReadStatus.Ready &&
|
||||||
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
|
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
|
||||||
!destination.startsWith("person/") && fixture == null) {
|
!destination.startsWith("person/") && fixture == null) {
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val seeAll: WolfskinSeeAllLists,
|
val seeAll: WolfskinSeeAllLists,
|
||||||
val conductarr: ConductarrSession,
|
val conductarr: ConductarrSession,
|
||||||
artwork: WolfskinArtwork,
|
artwork: WolfskinArtwork,
|
||||||
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
init { artwork.configure(context) }
|
init { artwork.configure(context) }
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||||
|
|
@ -196,7 +195,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
is ShellAction.Destination -> {
|
is ShellAction.Destination -> {
|
||||||
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
||||||
when {
|
when {
|
||||||
action.route == "settings" || action.route == "updates" -> navigation.navigateTo(target)
|
action.route == "settings" -> navigation.navigateTo(target)
|
||||||
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
|
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
|
||||||
else -> navigation.replace(target)
|
else -> navigation.replace(target)
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +262,6 @@ fun WolfskinApp(
|
||||||
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
||||||
) {
|
) {
|
||||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
|
|
||||||
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
||||||
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
|
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
|
||||||
LaunchedEffect(identity?.signedIn, identity?.person) {
|
LaunchedEffect(identity?.signedIn, identity?.person) {
|
||||||
|
|
@ -305,13 +303,7 @@ fun WolfskinApp(
|
||||||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(
|
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
||||||
destination,
|
|
||||||
contentKey = "${destination}_${server.id}_${user.id}",
|
|
||||||
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
|
|
||||||
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
|
|
||||||
} else emptyMap(),
|
|
||||||
) {
|
|
||||||
// Cached entries must not retain the launch shell after sign-in completes.
|
// Cached entries must not retain the launch shell after sign-in completes.
|
||||||
val state = LocalShellState.current
|
val state = LocalShellState.current
|
||||||
val discover = LocalDiscoverState.current
|
val discover = LocalDiscoverState.current
|
||||||
|
|
@ -337,15 +329,11 @@ fun WolfskinApp(
|
||||||
} else TitleDestination(route, state, preferences)
|
} else TitleDestination(route, state, preferences)
|
||||||
} else {
|
} else {
|
||||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||||
settingsInstalledVersion = installedVersion.versionName,
|
|
||||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||||
when {
|
when {
|
||||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
||||||
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
|
|
||||||
onBack = { state.navFocused = false; navigationManager.goBack() },
|
|
||||||
onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier)
|
|
||||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
||||||
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
||||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
// 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.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
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, asset: UpdateAsset, 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(asset.bytes)
|
|
||||||
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, asset.bytes).use { output ->
|
|
||||||
file.inputStream().use { copyVerifiedUpdate(it, output, asset) }
|
|
||||||
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 (error: Exception) {
|
|
||||||
sessionId?.let { runCatching { installer.abandonSession(it) } }
|
|
||||||
if (error is UpdateException) throw error
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check the bytes actually staged into Android, not a prior read of the private path. */
|
|
||||||
internal fun copyVerifiedUpdate(input: InputStream, output: OutputStream, asset: UpdateAsset) {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
val buffer = ByteArray(64 * 1024)
|
|
||||||
var count = 0L
|
|
||||||
while (true) {
|
|
||||||
val length = input.read(buffer)
|
|
||||||
if (length < 0) break
|
|
||||||
count += length
|
|
||||||
if (count > asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation")
|
|
||||||
digest.update(buffer, 0, length)
|
|
||||||
output.write(buffer, 0, length)
|
|
||||||
}
|
|
||||||
if (count != asset.bytes || digest.digest().toHexString() != asset.sha256) {
|
|
||||||
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
// 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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
/** Deterministic capture/test controller; no network, storage or Android hand-off. */
|
|
||||||
class FakeUpdateController(
|
|
||||||
initialState: UpdateUiState = UpdateUiState.Idle,
|
|
||||||
initialAutoCheck: Boolean = true,
|
|
||||||
initialVersion: InstalledVersion = InstalledVersion("1.1.1", 100001),
|
|
||||||
initialRelease: UpdateUiState.Available? = initialState as? UpdateUiState.Available,
|
|
||||||
) : UpdateController {
|
|
||||||
private val mutableState = MutableStateFlow(initialState)
|
|
||||||
private val mutableAutoCheck = MutableStateFlow(initialAutoCheck)
|
|
||||||
private val mutableVersion = MutableStateFlow(initialVersion)
|
|
||||||
private val mutableRelease = MutableStateFlow(initialRelease)
|
|
||||||
override val state = mutableState.asStateFlow()
|
|
||||||
override val autoCheck = mutableAutoCheck.asStateFlow()
|
|
||||||
override val installedVersion = mutableVersion.asStateFlow()
|
|
||||||
override val release = mutableRelease.asStateFlow()
|
|
||||||
val actions = mutableListOf<String>()
|
|
||||||
|
|
||||||
fun show(state: UpdateUiState) {
|
|
||||||
if (state is UpdateUiState.Available) mutableRelease.value = state
|
|
||||||
mutableState.value = state
|
|
||||||
}
|
|
||||||
fun showInstalled(version: InstalledVersion) { mutableVersion.value = version }
|
|
||||||
override fun check(manual: Boolean) { actions += "check:$manual" }
|
|
||||||
override fun download() { actions += "download" }
|
|
||||||
override fun cancel() { actions += "cancel" }
|
|
||||||
override fun install() { actions += "install" }
|
|
||||||
override fun retry() { actions += "retry" }
|
|
||||||
override fun onResume() { actions += "resume" }
|
|
||||||
override fun setAutoCheck(enabled: Boolean) { mutableAutoCheck.value = enabled }
|
|
||||||
}
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
// 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)
|
|
||||||
private val mutableRelease = MutableStateFlow<UpdateUiState.Available?>(null)
|
|
||||||
override val state = mutableState.asStateFlow()
|
|
||||||
override val installedVersion = mutableVersion.asStateFlow()
|
|
||||||
override val autoCheck = preferences.autoCheck
|
|
||||||
override val release = mutableRelease.asStateFlow()
|
|
||||||
private var candidate: UpdateCandidate? = null
|
|
||||||
private var ready: UpdateManifest? = null
|
|
||||||
private var operation: Job? = null
|
|
||||||
@Volatile private var installerActive = false
|
|
||||||
private var retryOperation: () -> Unit = { check(true) }
|
|
||||||
private val restored = scope.async {
|
|
||||||
store.restore(abis, sdk)?.let {
|
|
||||||
ready = it.manifest
|
|
||||||
mutableRelease.value = available(it.manifest, it.asset, it.notes)
|
|
||||||
mutableState.value = UpdateUiState.Ready(it.manifest.versionName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun runOperation(operationKind: UpdateOperation, 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, operationKind) }
|
|
||||||
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", operationKind)
|
|
||||||
}
|
|
||||||
catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed", operationKind) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun systemOwnsFlow(current: UpdateUiState): Boolean =
|
|
||||||
installerActive || current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded
|
|
||||||
|
|
||||||
override fun check(manual: Boolean): Unit = runOperation(UpdateOperation.Check) {
|
|
||||||
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) {
|
|
||||||
candidate = null; mutableRelease.value = 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; mutableRelease.value = null
|
|
||||||
mutableState.value = UpdateUiState.UpToDate
|
|
||||||
} else {
|
|
||||||
candidate = release
|
|
||||||
val metadata = available(release.manifest, release.asset, release.notes)
|
|
||||||
mutableRelease.value = metadata
|
|
||||||
mutableState.value = metadata
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun available(manifest: UpdateManifest, asset: UpdateAsset, notes: String): UpdateUiState.Available {
|
|
||||||
val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault())
|
|
||||||
.withZone(ZoneId.systemDefault()).format(Instant.parse(manifest.publishedAt))
|
|
||||||
return UpdateUiState.Available(manifest.versionName, asset.bytes, date, notes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun download(): Unit = runOperation(UpdateOperation.Download) {
|
|
||||||
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, release.notes)
|
|
||||||
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() {
|
|
||||||
transport.cancel()
|
|
||||||
val previous = operation
|
|
||||||
if (!installerActive) previous?.cancel()
|
|
||||||
operation = scope.launch {
|
|
||||||
previous?.join()
|
|
||||||
store.partial.delete()
|
|
||||||
mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) }
|
|
||||||
?: candidate?.let { mutableRelease.value } ?: UpdateUiState.Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun install(): Unit = runOperation(UpdateOperation.Install) {
|
|
||||||
if (installerActive || state.value == UpdateUiState.HandingOff) return@runOperation
|
|
||||||
val manifest = ready ?: return@runOperation
|
|
||||||
retryOperation = { install() }
|
|
||||||
val asset = manifest.selectAsset(abis, sdk)
|
|
||||||
try {
|
|
||||||
store.verify(store.apk, manifest, asset)
|
|
||||||
if (!platform.hasInstallPermission()) {
|
|
||||||
mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller
|
|
||||||
return@runOperation
|
|
||||||
}
|
|
||||||
currentCoroutineContext().ensureActive()
|
|
||||||
installerActive = true
|
|
||||||
mutableState.value = UpdateUiState.HandingOff
|
|
||||||
platform.install(store.apk, asset, ::handleInstallOutcome)
|
|
||||||
} catch (error: CancellationException) {
|
|
||||||
installerActive = false
|
|
||||||
throw error
|
|
||||||
} catch (error: Exception) {
|
|
||||||
installerActive = false
|
|
||||||
store.clear(); ready = null
|
|
||||||
retryOperation = { if (candidate != null) download() else check(true) }
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleInstallOutcome(outcome: InstallOutcome) {
|
|
||||||
scope.launch {
|
|
||||||
restored.await()
|
|
||||||
installerActive = outcome == InstallOutcome.Pending || outcome == InstallOutcome.Success
|
|
||||||
if (ready == null && outcome != InstallOutcome.Pending) {
|
|
||||||
installerActive = false
|
|
||||||
retryOperation = { check(true) }
|
|
||||||
mutableState.value = UpdateUiState.Idle
|
|
||||||
onResume()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
mutableState.value = when (outcome) {
|
|
||||||
InstallOutcome.Pending -> if (state.value is UpdateUiState.Ready) state.value else 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", UpdateOperation.Install)
|
|
||||||
InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained", UpdateOperation.Install)
|
|
||||||
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) {
|
|
||||||
installerActive = false
|
|
||||||
store.clear(); ready = null; candidate = null; mutableRelease.value = 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
/** Shared process-scoped updater for Wolfskin and classic settings. */
|
|
||||||
interface UpdateController {
|
|
||||||
val state: StateFlow<UpdateUiState>
|
|
||||||
val autoCheck: StateFlow<Boolean>
|
|
||||||
val installedVersion: StateFlow<InstalledVersion>
|
|
||||||
/** Candidate facts survive download/installer transitions and verified-file restoration. */
|
|
||||||
val release: StateFlow<UpdateUiState.Available?>
|
|
||||||
|
|
||||||
fun check(manual: Boolean)
|
|
||||||
fun download()
|
|
||||||
/** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */
|
|
||||||
fun cancel()
|
|
||||||
fun install()
|
|
||||||
fun retry()
|
|
||||||
fun setAutoCheck(enabled: Boolean)
|
|
||||||
|
|
||||||
/** Refresh installed identity and unknown-source permission after Android returns. */
|
|
||||||
fun onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PackageManager identity, refreshed after returning from the system installer. */
|
|
||||||
data class InstalledVersion(val versionName: String, val versionCode: Long)
|
|
||||||
|
|
||||||
/** Only verified, app-private bytes may produce Ready. Checking never initiates download. */
|
|
||||||
sealed interface UpdateUiState {
|
|
||||||
data object Idle : UpdateUiState
|
|
||||||
data object Checking : UpdateUiState
|
|
||||||
data object UpToDate : UpdateUiState
|
|
||||||
data object NoReleasePublished : UpdateUiState
|
|
||||||
data class Available(val versionName: String, val bytes: Long, val dateText: String, val notes: String) : UpdateUiState
|
|
||||||
data class Downloading(val bytesDone: Long, val bytesTotal: Long?) : UpdateUiState
|
|
||||||
data class Ready(val versionName: String) : UpdateUiState
|
|
||||||
data object PermissionNeeded : UpdateUiState
|
|
||||||
data object HandingOff : UpdateUiState
|
|
||||||
data object InstallCancelled : UpdateUiState
|
|
||||||
data class Failed(val kind: UpdateFailure, val message: String, val operation: UpdateOperation) : UpdateUiState
|
|
||||||
data object Offline : UpdateUiState
|
|
||||||
data object NoInstaller : UpdateUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The failed action is structured; UI must not infer it from a reason string. */
|
|
||||||
enum class UpdateOperation { Check, Download, Install }
|
|
||||||
|
|
||||||
/** Failures are distinct from a successful version comparison or an empty channel. */
|
|
||||||
enum class UpdateFailure {
|
|
||||||
Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage,
|
|
||||||
ChecksumMismatch, WrongPackage, WrongSigner, LowerVersionCode,
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import org.justinvisser.wolfskin.design.ShellContentFocus
|
|
||||||
import org.justinvisser.wolfskin.design.ShellKey
|
|
||||||
|
|
||||||
/** Action focus survives progress emissions; release notes own every key until Back. */
|
|
||||||
class UpdateFocus(
|
|
||||||
private val controller: UpdateController,
|
|
||||||
private val back: () -> Unit,
|
|
||||||
private val classic: () -> Unit,
|
|
||||||
private val nav: () -> Unit,
|
|
||||||
private val scrollNotes: (Int) -> Unit,
|
|
||||||
) : ShellContentFocus {
|
|
||||||
override val cardKey: String? = null
|
|
||||||
var actionIndex by mutableIntStateOf(0)
|
|
||||||
private set
|
|
||||||
var autoFocused by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
var notesOpen by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
private var actions = emptyList<UpdateAction>()
|
|
||||||
private var previousState: Class<*>? = null
|
|
||||||
|
|
||||||
/** Only a changed operation may retire an action; progress cannot steal focus. */
|
|
||||||
fun update(state: UpdateUiState, value: List<UpdateAction>) {
|
|
||||||
if (previousState != state.javaClass) {
|
|
||||||
actionIndex = 0
|
|
||||||
notesOpen = false
|
|
||||||
if (state == UpdateUiState.InstallCancelled || state is UpdateUiState.Ready) autoFocused = false
|
|
||||||
}
|
|
||||||
previousState = state.javaClass
|
|
||||||
actions = value
|
|
||||||
actionIndex = actionIndex.coerceAtMost(actions.lastIndex.coerceAtLeast(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun focusAction(index: Int) { actionIndex = index; autoFocused = false }
|
|
||||||
|
|
||||||
fun activate(index: Int) {
|
|
||||||
focusAction(index)
|
|
||||||
when (actions[index].command) {
|
|
||||||
UpdateCommand.Back -> back()
|
|
||||||
UpdateCommand.Check -> controller.check(manual = true)
|
|
||||||
UpdateCommand.Download -> controller.download()
|
|
||||||
UpdateCommand.Notes -> notesOpen = true
|
|
||||||
UpdateCommand.Cancel -> controller.cancel()
|
|
||||||
UpdateCommand.Install -> controller.install()
|
|
||||||
UpdateCommand.Retry -> controller.retry()
|
|
||||||
UpdateCommand.Classic -> classic()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleAuto() { autoFocused = true; controller.setAutoCheck(!controller.autoCheck.value) }
|
|
||||||
|
|
||||||
override fun key(key: ShellKey): Boolean {
|
|
||||||
if (notesOpen) {
|
|
||||||
when (key) {
|
|
||||||
ShellKey.Back -> notesOpen = false
|
|
||||||
ShellKey.Up -> scrollNotes(-1)
|
|
||||||
ShellKey.Down -> scrollNotes(1)
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
when (key) {
|
|
||||||
ShellKey.Back -> back()
|
|
||||||
ShellKey.Up -> if (autoFocused) autoFocused = false else nav()
|
|
||||||
ShellKey.Down -> autoFocused = true
|
|
||||||
ShellKey.Left -> if (!autoFocused) actionIndex = (actionIndex - 1).coerceAtLeast(0)
|
|
||||||
ShellKey.Right -> if (!autoFocused) actionIndex = (actionIndex + 1).coerceAtMost(actions.lastIndex.coerceAtLeast(0))
|
|
||||||
ShellKey.Ok -> if (autoFocused) toggleAuto() else if (actions.isNotEmpty()) activate(actionIndex)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
// 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)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import androidx.compose.animation.EnterTransition
|
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
|
||||||
|
|
||||||
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
|
|
||||||
internal val updatesRouteTransitions =
|
|
||||||
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
|
||||||
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
|
||||||
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import java.text.NumberFormat
|
|
||||||
|
|
||||||
/** Screen commands remain explicit; no state transition implicitly installs an APK. */
|
|
||||||
enum class UpdateCommand { Back, Check, Download, Notes, Cancel, Install, Retry, Classic }
|
|
||||||
|
|
||||||
/** An action is a user command, not a focusable progress indicator. */
|
|
||||||
data class UpdateAction(val label: String, val command: UpdateCommand, val primary: Boolean = false)
|
|
||||||
|
|
||||||
/** Plain copy for the process controller's state; bytes and notes remain real controller data. */
|
|
||||||
data class UpdatePresentation(
|
|
||||||
val title: String = "",
|
|
||||||
val summary: String = "",
|
|
||||||
val paragraphs: List<String> = emptyList(),
|
|
||||||
val actions: List<UpdateAction>,
|
|
||||||
val progress: Boolean = false,
|
|
||||||
val fraction: Float? = null,
|
|
||||||
val byteProgress: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Decimal megabytes match release sizes; unknown totals never create a percentage. */
|
|
||||||
fun formatUpdateBytes(bytes: Long): String = NumberFormat.getNumberInstance().apply {
|
|
||||||
minimumFractionDigits = 1
|
|
||||||
maximumFractionDigits = 1
|
|
||||||
}.format(bytes / 1_000_000.0) + " MB"
|
|
||||||
|
|
||||||
/** Metadata uses the controller's locale-formatted release date. */
|
|
||||||
fun updateMetadata(release: UpdateUiState.Available?): String = release?.let {
|
|
||||||
"${it.versionName} · ${formatUpdateBytes(it.bytes)} · ${it.dateText}"
|
|
||||||
}.orEmpty()
|
|
||||||
|
|
||||||
/** Every controller outcome is distinct from a successful compatible version comparison. */
|
|
||||||
fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updatedVersion: String? = null): UpdatePresentation {
|
|
||||||
val back = UpdateAction("Back", UpdateCommand.Back)
|
|
||||||
val notes = UpdateAction("Release notes", UpdateCommand.Notes)
|
|
||||||
val retry = UpdateAction("Retry", UpdateCommand.Retry, true)
|
|
||||||
val metadata = updateMetadata(release)
|
|
||||||
return when (state) {
|
|
||||||
UpdateUiState.Idle -> updatedVersion?.let(::updatedPresentation)
|
|
||||||
?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
|
|
||||||
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
|
|
||||||
UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation)
|
|
||||||
?: UpdatePresentation("Up to date", "No newer release for this TV.",
|
|
||||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
|
|
||||||
UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.",
|
|
||||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
|
|
||||||
is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state),
|
|
||||||
state.notes.lineSequence().filter { it.isNotBlank() }.take(3).toList(),
|
|
||||||
listOf(UpdateAction("Download", UpdateCommand.Download, true), notes))
|
|
||||||
is UpdateUiState.Downloading -> {
|
|
||||||
val total = state.bytesTotal?.takeIf { it > 0 }
|
|
||||||
val fraction = total?.let { (state.bytesDone.toDouble() / it).coerceIn(0.0, 1.0).toFloat() }
|
|
||||||
val bytes = total?.let { "${formatUpdateBytes(state.bytesDone).removeSuffix(" MB")} / ${formatUpdateBytes(it)}" }
|
|
||||||
?: formatUpdateBytes(state.bytesDone)
|
|
||||||
UpdatePresentation(fraction?.let { "${(it * 100).toInt()}%" }.orEmpty(), metadata,
|
|
||||||
actions = listOf(UpdateAction("Cancel download", UpdateCommand.Cancel)), progress = true, fraction = fraction, byteProgress = bytes)
|
|
||||||
}
|
|
||||||
is UpdateUiState.Ready -> UpdatePresentation("Ready to install", metadata.ifEmpty { state.versionName },
|
|
||||||
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Install update", UpdateCommand.Install, true), notes))
|
|
||||||
UpdateUiState.PermissionNeeded -> UpdatePresentation("Allow updates on this TV", "Enable Wolfskin in Install unknown apps, then return.",
|
|
||||||
actions = listOf(UpdateAction("Open TV settings", UpdateCommand.Install, true), UpdateAction("Not now", UpdateCommand.Cancel)))
|
|
||||||
UpdateUiState.HandingOff -> UpdatePresentation("Continue in Android", "Confirm the update on the system screen.",
|
|
||||||
listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel)))
|
|
||||||
UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata,
|
|
||||||
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes))
|
|
||||||
is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind, state.operation), state.message, actions = listOf(retry, back))
|
|
||||||
UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.",
|
|
||||||
actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true)))
|
|
||||||
UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.",
|
|
||||||
listOf("The download is kept. Classic settings may offer the same system limitation."),
|
|
||||||
listOf(retry, UpdateAction("Classic settings (trial)", UpdateCommand.Classic)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatedPresentation(version: String) = UpdatePresentation(
|
|
||||||
"Updated to $version", "Sign-ins and settings kept.",
|
|
||||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String {
|
|
||||||
if (operation == UpdateOperation.Install) return "Install failed"
|
|
||||||
return when (kind) {
|
|
||||||
UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage ->
|
|
||||||
if (operation == UpdateOperation.Check) "Check failed" else "Download failed"
|
|
||||||
UpdateFailure.InvalidMetadata ->
|
|
||||||
if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed"
|
|
||||||
UpdateFailure.UnsupportedAbi -> "Update unavailable"
|
|
||||||
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
|
||||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import org.justinvisser.wolfskin.design.reducedMotion
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
|
|
||||||
|
|
||||||
/** Draw-only bounded track: changing bytes never moves logical or Android focus. */
|
|
||||||
@Composable
|
|
||||||
fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) {
|
|
||||||
val reduced = reducedMotion()
|
|
||||||
val phase = if (reduced) 0f else {
|
|
||||||
val transition = rememberInfiniteTransition(label = "update-progress")
|
|
||||||
val animated by transition.animateFloat(0f, 1f,
|
|
||||||
infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen,
|
|
||||||
easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight")
|
|
||||||
animated
|
|
||||||
}
|
|
||||||
Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress")
|
|
||||||
.semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) {
|
|
||||||
val radius = CornerRadius(U.progressRadius.toPx())
|
|
||||||
drawRoundRect(C.surface3, cornerRadius = radius)
|
|
||||||
val width = size.width * (fraction ?: U.scanFraction)
|
|
||||||
val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase)
|
|
||||||
clipRect {
|
|
||||||
drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius)
|
|
||||||
if (!reduced && fraction != null && width > 0f) {
|
|
||||||
clipRect(0f, 0f, width, size.height) {
|
|
||||||
val start = width * (-1f + 2f * phase)
|
|
||||||
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent),
|
|
||||||
startX = start, endX = start + width), size = Size(width, size.height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.role
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.justinvisser.wolfskin.design.Avatar
|
|
||||||
import org.justinvisser.wolfskin.design.CssColumn
|
|
||||||
import org.justinvisser.wolfskin.design.PickerButton
|
|
||||||
import org.justinvisser.wolfskin.design.ShellState
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
|
||||||
import org.justinvisser.wolfskin.design.exactLineBox
|
|
||||||
import org.justinvisser.wolfskin.design.focusScale
|
|
||||||
import org.justinvisser.wolfskin.design.motionDuration
|
|
||||||
import org.justinvisser.wolfskin.design.reducedMotion
|
|
||||||
import org.justinvisser.wolfskin.design.resolveUpdateAvatarColor
|
|
||||||
import org.justinvisser.wolfskin.design.rowReveal
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
|
||||||
import org.justinvisser.wolfskin.design.UpdateTypography as T
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
|
|
||||||
|
|
||||||
/** Controller lifetime belongs to the process, while navigation owns this screen's focus. */
|
|
||||||
@Composable
|
|
||||||
fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> Unit, onClassic: () -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
val state by controller.state.collectAsStateWithLifecycle()
|
|
||||||
val installed by controller.installedVersion.collectAsStateWithLifecycle()
|
|
||||||
val release by controller.release.collectAsStateWithLifecycle()
|
|
||||||
val autoCheck by controller.autoCheck.collectAsStateWithLifecycle()
|
|
||||||
val initialInstalledCode = remember { installed.versionCode }
|
|
||||||
val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode }
|
|
||||||
val presentation = remember(state, release, updatedVersion) {
|
|
||||||
presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate })
|
|
||||||
}
|
|
||||||
val scroll = rememberScrollState()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val reduced = reducedMotion()
|
|
||||||
val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced))
|
|
||||||
val focus = remember(controller, shell) {
|
|
||||||
UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction ->
|
|
||||||
scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction,
|
|
||||||
tween(scrollDuration, easing = M.easeOut)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) }
|
|
||||||
LaunchedEffect(controller) { if (controller.state.value == UpdateUiState.Idle) controller.check(manual = true) }
|
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { controller.onResume() }
|
|
||||||
DisposableEffect(shell, focus) {
|
|
||||||
shell.contentFocus = focus
|
|
||||||
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null; shell.contentOverlayVisible = false }
|
|
||||||
}
|
|
||||||
LaunchedEffect(focus.notesOpen) { shell.contentOverlayVisible = focus.notesOpen }
|
|
||||||
Box(modifier.fillMaxSize().testTag("updates-screen")) {
|
|
||||||
Row(Modifier.offset(L.safeSide, U.headerTop).height(U.headerHeight),
|
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
|
||||||
Avatar(shell.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(shell.person))
|
|
||||||
Text("Updates", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
|
|
||||||
Text("Installed ${installed.versionName}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
|
|
||||||
}
|
|
||||||
Text("Back · Settings", Modifier.align(Alignment.TopEnd).padding(end = L.safeSide).offset(y = U.backTop), style = T.label.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
|
|
||||||
if (focus.notesOpen) {
|
|
||||||
NotesPanel(release, scroll)
|
|
||||||
} else {
|
|
||||||
UpdatePanel(presentation, focus, !shell.navFocused, state.javaClass)
|
|
||||||
AutoCheckRow(autoCheck, focus.autoFocused && !shell.navFocused, Modifier.offset(L.safeSide, U.autoTop), focus::toggleAuto)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UpdatePanel(presentation: UpdatePresentation, focus: UpdateFocus, focused: Boolean, identity: Any) {
|
|
||||||
val shape = RoundedCornerShape(U.radius)
|
|
||||||
CssColumn(Modifier.offset(L.safeSide, U.contentTop).width(U.width).rowReveal(identity)
|
|
||||||
.background(C.surface, shape).border(U.border, C.border, shape).padding(U.padding + U.border)) {
|
|
||||||
if (presentation.title.isNotEmpty()) {
|
|
||||||
Text(presentation.title, Modifier.exactLineBox(T.heading), style = T.heading, color = C.fg)
|
|
||||||
Spacer(Modifier.height(U.titleBottom))
|
|
||||||
}
|
|
||||||
if (presentation.summary.isNotEmpty()) {
|
|
||||||
Text(presentation.summary, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
|
||||||
if (presentation.paragraphs.isNotEmpty()) Spacer(Modifier.height(U.paragraphBottom))
|
|
||||||
}
|
|
||||||
presentation.paragraphs.forEachIndexed { index, paragraph ->
|
|
||||||
Text(paragraph, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
|
||||||
if (index != presentation.paragraphs.lastIndex) Spacer(Modifier.height(U.paragraphBottom))
|
|
||||||
}
|
|
||||||
if (presentation.progress) {
|
|
||||||
Spacer(Modifier.height(U.progressTop))
|
|
||||||
UpdateProgress(presentation.fraction)
|
|
||||||
presentation.byteProgress?.let {
|
|
||||||
Spacer(Modifier.height(U.progressBottom))
|
|
||||||
Text(it, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(Modifier.padding(top = U.actionsTop), horizontalArrangement = Arrangement.spacedBy(U.actionGap)) {
|
|
||||||
presentation.actions.forEachIndexed { index, action ->
|
|
||||||
Box(Modifier.testTag("update-action-${action.command}")) {
|
|
||||||
PickerButton(action.label, focused && !focus.autoFocused && focus.actionIndex == index, action.primary,
|
|
||||||
textStyle = T.button, onFocus = { focus.focusAction(index) }) { focus.activate(index) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AutoCheckRow(enabled: Boolean, focused: Boolean, modifier: Modifier, onToggle: () -> Unit) {
|
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.listRow)
|
|
||||||
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).testTag("update-auto-check")
|
|
||||||
.semantics { role = Role.Switch; stateDescription = if (enabled) "On" else "Off" }
|
|
||||||
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
|
||||||
.background(if (focused) C.fg else C.listRowRest, shape).clickable(onClick = onToggle)
|
|
||||||
.padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text("Auto-check for updates", Modifier.exactLineBox(T.listTitle), style = T.listTitle, color = if (focused) C.onFocus else C.fg)
|
|
||||||
Text("${if (enabled) "On" else "Off"} · checks only, never installs", Modifier.exactLineBox(T.caption), style = T.caption, color = if (focused) C.onFocus else C.muted)
|
|
||||||
}
|
|
||||||
Box(Modifier.size(U.toggleWidth, U.toggleHeight).background(if (focused || !enabled) C.surface3 else C.accentSoft, CircleShape)) {
|
|
||||||
Box(Modifier.align(if (enabled) Alignment.CenterEnd else Alignment.CenterStart).padding(horizontal = U.toggleInset)
|
|
||||||
.size(U.toggleThumb).background(if (enabled) C.accentText else C.muted, CircleShape))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compose.foundation.ScrollState) {
|
|
||||||
val shape = RoundedCornerShape(U.radius)
|
|
||||||
val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() }
|
|
||||||
val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal)
|
|
||||||
CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
|
|
||||||
.drawWithCache {
|
|
||||||
val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f
|
|
||||||
val radius = U.radius.toPx()
|
|
||||||
val inset = U.border.toPx() + ring / 2f
|
|
||||||
onDrawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring),
|
|
||||||
CornerRadius(radius + ring / 2f), style = Stroke(ring))
|
|
||||||
drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2),
|
|
||||||
CornerRadius(radius - inset), style = Stroke(ring))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(C.surface, shape).border(U.border, C.border, shape)
|
|
||||||
.verticalScroll(scroll).padding(U.padding + U.border)) {
|
|
||||||
Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg)
|
|
||||||
Spacer(Modifier.height(U.notesListTop))
|
|
||||||
paragraphs.forEachIndexed { index, paragraph ->
|
|
||||||
Row(Modifier.padding(start = U.notesMarkerOffset).exactLineBox(notesStyle)) {
|
|
||||||
Text("•", Modifier.width(U.notesMarkerWidth).exactLineBox(notesStyle), style = notesStyle, color = C.muted)
|
|
||||||
Text(paragraph.removePrefix("- "), Modifier.exactLineBox(notesStyle), style = notesStyle, color = C.muted)
|
|
||||||
}
|
|
||||||
if (index != paragraphs.lastIndex) Spacer(Modifier.height(U.notesGap))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(U.notesListBottom))
|
|
||||||
Text("Sign-in stays on this TV. Android asks before installing.", Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv.update
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.contentOrNull
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
/** 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, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Installer results do not imply the running process has been replaced. */
|
|
||||||
enum class InstallOutcome { Pending, Success, Cancelled, Failed, Unavailable, InsufficientStorage }
|
|
||||||
|
|
||||||
/** Metadata returned only after verifying the persisted APK against this release. */
|
|
||||||
data class VerifiedRelease(val manifest: UpdateManifest, val asset: UpdateAsset, val notes: String)
|
|
||||||
|
|
||||||
/** 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, notes: String) {
|
|
||||||
verify(partial, manifest, asset)
|
|
||||||
check(partial.renameTo(apk)) { "Could not commit verified APK" }
|
|
||||||
val temporary = File(directory, "ready.pending")
|
|
||||||
val metadata = Json.parseToJsonElement(UpdateManifest.encode(manifest)).jsonObject
|
|
||||||
val stored = JsonObject(metadata + ("releaseNotes" to JsonPrimitive(notes)))
|
|
||||||
temporary.outputStream().use { it.write(stored.toString().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): VerifiedRelease? {
|
|
||||||
partial.delete()
|
|
||||||
File(directory, "ready.pending").delete()
|
|
||||||
if (!record.exists()) { apk.delete(); return null }
|
|
||||||
return try {
|
|
||||||
val text = record.readText()
|
|
||||||
val manifest = UpdateManifest.parse(text)
|
|
||||||
val asset = manifest.selectAsset(abis, sdk)
|
|
||||||
verify(apk, manifest, asset)
|
|
||||||
val notes = Json.parseToJsonElement(text).jsonObject["releaseNotes"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
|
||||||
VerifiedRelease(manifest, asset, notes)
|
|
||||||
} 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -54,6 +54,8 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi
|
||||||
|
|
||||||
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
|
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
|
||||||
|
|
||||||
|
Round-2 Discover navigation amendment: Up/Down into an untouched lane starts at its leftmost item, not the previous lane's nearest column. A moved lane restores its own selected item and horizontal track for this visit. Pushed titles and service/collection pages preserve that visit; selecting another root tab or rebuilding Discover clears its lane positions. Root vertical position, filters and hero selection retain their existing shell policy. Home's nearest-centre shelves and Library's column-preserving continuous grids are unchanged pending a separate behavior decision.
|
||||||
|
|
||||||
## Motion and loading — revision 6
|
## Motion and loading — revision 6
|
||||||
|
|
||||||
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
|
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
|
||||||
|
|
@ -64,17 +66,18 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo
|
||||||
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
|
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
|
||||||
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
|
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
|
||||||
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
|
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
|
||||||
| Initial hero/title copy | 180 ms, ease-out | 16 px rise |
|
| Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance |
|
||||||
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
|
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
|
||||||
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry |
|
| Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped |
|
||||||
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
|
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
|
||||||
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
||||||
|
| Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms |
|
||||||
|
|
||||||
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static.
|
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, without an animated screen-sized layer. Hero copy is a separate bounded block with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. Existing poster-only blur is static.
|
||||||
|
|
||||||
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
||||||
|
|
||||||
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route.
|
The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion.
|
||||||
|
|
||||||
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
||||||
|
|
||||||
|
|
@ -82,6 +85,8 @@ Discover key traversal skips pending lanes in both directions. If every remainin
|
||||||
|
|
||||||
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
|
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
|
||||||
|
|
||||||
|
The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements.
|
||||||
|
|
||||||
|
|
||||||
## Card metadata and bottom state line — revision 6, option B
|
## Card metadata and bottom state line — revision 6, option B
|
||||||
|
|
||||||
|
|
|
||||||
279
docker/README.md
279
docker/README.md
|
|
@ -11,11 +11,7 @@ make test # app default/debug suite plus wolfskin-design JVM/capture suite
|
||||||
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
|
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
|
||||||
```
|
```
|
||||||
|
|
||||||
Offline JVM tests use instrumented Android 9, 11 and 14 SDK jars. Cache preparation
|
APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds
|
||||||
resolves each SDK independently; putting all three versions in one Gradle
|
|
||||||
configuration selects only the newest and prevents older-SDK tests from starting.
|
|
||||||
|
|
||||||
APKs remain under `app/build/outputs/apk/default/{release,debug,optimized}/`. Debug builds
|
|
||||||
are not playback-complete and must not be distributed as releases. Upstream's
|
are not playback-complete and must not be distributed as releases. Upstream's
|
||||||
`.github/workflows` and dependency version catalog are unchanged; release builds
|
`.github/workflows` and dependency version catalog are unchanged; release builds
|
||||||
use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not
|
use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not
|
||||||
|
|
@ -31,159 +27,6 @@ Upstream enables JVM unit tests only for debug variants; there is no
|
||||||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||||||
task without changing upstream's test configuration.
|
task without changing upstream's test configuration.
|
||||||
|
|
||||||
## Household update channel
|
|
||||||
|
|
||||||
The household assembly remains `defaultOptimized`: R8/resource shrinking,
|
|
||||||
non-debuggable, baseline profiles, pinned playback extensions and no debug
|
|
||||||
fixtures. Its installation identity is `org.justinvisser.wolfskin.tv.opt`.
|
|
||||||
The persistent external Android debug keystore is now the **household signing
|
|
||||||
authority**, not a disposable development key. Certificate SHA-256:
|
|
||||||
`182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d`.
|
|
||||||
Never print/copy that keystore into artifacts or regenerate it for a release.
|
|
||||||
The household wrapper fails if it is missing; protect and escrow the original.
|
|
||||||
|
|
||||||
Same package + same signer + strictly higher Android versionCode allows an
|
|
||||||
in-place upgrade retaining Jellyfin logins, databases and preferences. Never
|
|
||||||
uninstall, clear data, change identity or replace the key as an update recovery
|
|
||||||
step. An older ephemeral signer is a separate, owner-approved migration gate;
|
|
||||||
this change does not prove the TV's installed signer or retained-login behavior.
|
|
||||||
|
|
||||||
The dedicated public distribution repository is
|
|
||||||
`justinzeus/wolfskin-tv-updates`; its default API is
|
|
||||||
`https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest`.
|
|
||||||
Repository creation and each actual publication are separate owner gates.
|
|
||||||
Neither command creates a repository. Source remains in `justinzeus/wolfskin`.
|
|
||||||
Initialize the distribution repository's default branch (for example with a
|
|
||||||
README) before its first draft; Forgejo needs a commit on which to create a
|
|
||||||
distribution tag. That tag is not the source tag in the separate source repo.
|
|
||||||
The old production package keeps its old release API and parser. Only the Opt
|
|
||||||
package migrates the exact old default saved URL; intentional custom URLs remain.
|
|
||||||
Installed-release notes use the household channel too.
|
|
||||||
|
|
||||||
### Build, inspect and reserve a code
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
|
|
||||||
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
|
|
||||||
# Prerequisites only: no build, tag, ledger mutation or publication.
|
|
||||||
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
|
|
||||||
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-dry TV_RELEASE_FLAGS=--dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
Required: a clean committed source checkout, the existing persistent key, native
|
|
||||||
package credentials through the existing secret mount, no local AAR overrides,
|
|
||||||
and an explicit code above the committed ledgers at HEAD and freshly fetched
|
|
||||||
`origin/main`, plus every local consumed-code reservation. Fetch failure refuses
|
|
||||||
the build, including a dry-run. Known Opt code 65 seeds the ledger; old production
|
|
||||||
code 28 and Wholphin code 62 belong to different packages. Proposed first household
|
|
||||||
code 100001 exceeds all three. No household code derives from tag counts.
|
|
||||||
Only `make tv-release` produces an installable household release; direct optimized
|
|
||||||
Gradle builds bypass reservation and must never be installed on the household box.
|
|
||||||
|
|
||||||
The builder inspects final signed APKs with aapt2/apksigner and checks package,
|
|
||||||
version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory,
|
|
||||||
fixture exclusion and both dexopt profiles. It emits four standalone APKs:
|
|
||||||
`Wolfskin-armeabi-v7a.apk`, `Wolfskin-arm64-v8a.apk`, `Wolfskin-x86_64.apk`,
|
|
||||||
and universal `Wolfskin.apk`; plus `wolfskin-update.json`, `RELEASE-NOTES.md`
|
|
||||||
and `SHA256SUMS`. Hashes and sizes come from final signed bytes. Universal
|
|
||||||
32-bit x86 is not MPV-complete and is not a supported playback ABI.
|
|
||||||
|
|
||||||
Manifest schema 1 contains `packageId`, `versionName`, `versionCode`,
|
|
||||||
`publishedAt` (UTC ISO-8601), immutable `sourceCommit`, `sourceTag`,
|
|
||||||
`signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`.
|
|
||||||
All ABI outputs share one code/signer/identity. The local source tag is
|
|
||||||
`pwolfskin-opt-v<VERSION>`; the distribution tag is `wolfskin-opt-v<VERSION>`.
|
|
||||||
Before building, the code is consumed in the local Git ref
|
|
||||||
`refs/wolfskin-tv/version-codes/<code>`. This survives discarding a working-tree
|
|
||||||
ledger append or checking out an older source commit. A failed real build also
|
|
||||||
consumes its code: use a higher code rather than deleting the reservation.
|
|
||||||
After successful packaging the builder appends the code to the tracked ledger.
|
|
||||||
Commit that reservation and explicitly push the source tag before distribution;
|
|
||||||
new producer checkouts obtain the committed floor by fetching `origin/main`.
|
|
||||||
An existing output directory is refused to preserve previous evidence.
|
|
||||||
|
|
||||||
Keep `private/` private: build/dependency logs, R8 mapping/effective configuration,
|
|
||||||
APK metadata/certificate inspection and native/ZIP inventory. Publication uploads
|
|
||||||
only the seven named public files, never that directory, credentials or keys.
|
|
||||||
Review release notes and corresponding-source/native links before promotion.
|
|
||||||
|
|
||||||
### Explicit draft publication
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
|
|
||||||
TV_PUBLISH_FLAGS=--dry-run
|
|
||||||
# Only after owner approval to upload:
|
|
||||||
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
|
|
||||||
# Only after owner approval to publish the verified draft (use its printed id):
|
|
||||||
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
|
|
||||||
TV_PUBLISH_FLAGS='--draft-id <id> --promote'
|
|
||||||
make tv-tooling-test
|
|
||||||
```
|
|
||||||
|
|
||||||
The publisher reads git's credential helper into process memory, not arguments,
|
|
||||||
logs or files. It creates a draft, uploads all public files, then downloads and
|
|
||||||
hashes their bytes while still draft. Existing matching drafts can be resumed
|
|
||||||
and reverified. Upload/verification failures remain draft. Promotion requires
|
|
||||||
the explicit flag; then anonymous latest/asset hash checks must pass. A failed
|
|
||||||
post-promotion check attempts withdrawal to draft and reports failure, not
|
|
||||||
success. Cross-origin credential forwarding and non-HTTPS URLs are refused.
|
|
||||||
|
|
||||||
Before creating/resuming a draft and immediately before promotion, the publisher
|
|
||||||
anonymously reads latest and its manifest. The bundle code must be strictly newer.
|
|
||||||
Only a latest 404 followed by a successful empty release list permits a first
|
|
||||||
release; other errors fail closed. A channel advance during upload leaves the
|
|
||||||
verified upload draft rather than publishing an equal or lower code.
|
|
||||||
`--test-server` permits only a loopback HTTP fake Forgejo; production defaults
|
|
||||||
cannot be redirected to a different repository by a CLI repository argument.
|
|
||||||
Dry-run never fetches a credential or makes publication requests.
|
|
||||||
|
|
||||||
### Client trust and installation
|
|
||||||
|
|
||||||
`UpdateController` is the process-scoped UI contract with `StateFlow` state,
|
|
||||||
shared auto-check preference, refreshed PackageManager installed version and
|
|
||||||
explicit check/download/cancel/install/retry/resume actions. Its capture fake
|
|
||||||
does not perform real operations. Classic Opt update controls use that same
|
|
||||||
controller; the old package retains its original updater.
|
|
||||||
|
|
||||||
`release: StateFlow<UpdateUiState.Available?>` retains version, size, localized
|
|
||||||
date and release notes through download, Ready and installation cancellation.
|
|
||||||
Verified records persist notes as an optional `releaseNotes` field alongside
|
|
||||||
the manifest; existing manifest-only records remain readable. `Failed.operation`
|
|
||||||
is `UpdateOperation.Check`, `Download` or `Install`, never inferred from message text.
|
|
||||||
Permission and handoff dismissals restore verified Ready without discarding its
|
|
||||||
APK. Installer ownership is tracked separately from the visible state, so a
|
|
||||||
dismissed but active system session still blocks a duplicate handoff. Calling
|
|
||||||
`install()` from PermissionNeeded resolves and opens the permission settings again.
|
|
||||||
|
|
||||||
The client uses a dedicated anonymous OkHttp client, validating every redirect:
|
|
||||||
HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential.
|
|
||||||
Loopback HTTP is available only through the test transport constructor flag.
|
|
||||||
It selects armeabi-v7a first when supported, then remaining device ABIs and
|
|
||||||
a universal fallback, subject to minSdk. Universal fallback is limited to
|
|
||||||
the channel's supported playback ABIs: armeabi-v7a, arm64-v8a and x86_64.
|
|
||||||
It cannot make an unsupported processor compatible. Empty-release-list success
|
|
||||||
is distinct from a latest-endpoint 404, authorization failure, invalid metadata
|
|
||||||
or up-to-date code.
|
|
||||||
|
|
||||||
Downloads are app-private. Retry starts a new truncated partial rather than
|
|
||||||
concatenating unvalidated ranges; cancel and process recreation discard partials.
|
|
||||||
Length, SHA-256, APK PackageManager package/version/current signer and manifest
|
|
||||||
signer must agree before Ready. Restoring Ready re-hashes/re-inspects the APK.
|
|
||||||
Checksum files on the same server are not an independent trust authority:
|
|
||||||
the installed Android signer is the installation boundary.
|
|
||||||
|
|
||||||
EOF never starts installation. Install requires a separate OK, per-package
|
|
||||||
unknown-app permission and a confirmation-required PackageInstaller session.
|
|
||||||
The bytes streamed into that session are checked again against the manifest length
|
|
||||||
and hash; any mismatch abandons the session before commit and discards invalid Ready.
|
|
||||||
Returning from permission settings requires another explicit Install press.
|
|
||||||
Denial, cancellation and missing system handlers retain the verified download.
|
|
||||||
No broad FileProvider, public Downloads write, silent install or automatic
|
|
||||||
uninstall is involved. PackageManager is re-read on return; a successful callback
|
|
||||||
is not proof that the running process is the new version. Real OEM confirmation,
|
|
||||||
process replacement and retained-login acceptance remain device-walk gates.
|
|
||||||
|
|
||||||
|
|
||||||
### Full-suite test diagnostics
|
### Full-suite test diagnostics
|
||||||
|
|
||||||
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
|
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
|
||||||
|
|
@ -246,22 +89,20 @@ wait for either response. The minified SDK log tag is not stable across builds.
|
||||||
|
|
||||||
The `optimized` build type inherits `release`: R8 optimization/minification,
|
The `optimized` build type inherits `release`: R8 optimization/minification,
|
||||||
resource shrinking, non-debuggable code, dependency consumer keep rules and
|
resource shrinking, non-debuggable code, dependency consumer keep rules and
|
||||||
dependency baseline profiles. Its package (`org.justinvisser.wolfskin.tv.opt`),
|
dependency baseline profiles. It changes only signing (the Android debug key),
|
||||||
launcher label (`Wolfskin (Opt)`) and persistent signer remain unchanged. It is
|
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
|
||||||
the household release assembly, with plain versionName `1.1.1` and an explicit
|
The launcher icon/banner and release-compatible version name are unchanged:
|
||||||
monotonic versionCode; source commit identity is recorded separately.
|
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
|
||||||
`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and
|
`make apk` remains the clean, release-key-signed production build. Debug and
|
||||||
release signer. It must never publish to the household channel.
|
optimized containers share the persistent debug key described below; release
|
||||||
|
signing is unchanged.
|
||||||
|
|
||||||
For build-only diagnostics, build all four outputs through the container wrapper
|
Build all four default-flavor outputs in one invocation through the container
|
||||||
with a deliberately non-release code. **Direct optimized builds must never be
|
wrapper, without a release keystore. On a host whose Gradle cache already holds
|
||||||
installed on the household box or published.** Use `make tv-release` for every
|
the pinned native extensions and their metadata:
|
||||||
household installation. On a host whose Gradle cache already holds the pinned
|
|
||||||
native extensions and their metadata:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bash docker/gradle.sh optimized --offline \
|
bash docker/gradle.sh optimized --offline \
|
||||||
-PtvVersion=1.1.1 -PtvVersionCode=1 \
|
|
||||||
-PWholphinExtensionsUsername=JustinZeus \
|
-PWholphinExtensionsUsername=JustinZeus \
|
||||||
-PWholphinExtensionsPassword=unused \
|
-PWholphinExtensionsPassword=unused \
|
||||||
:app:assembleDefaultOptimized
|
:app:assembleDefaultOptimized
|
||||||
|
|
@ -274,8 +115,7 @@ through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
|
||||||
or securely seeded cached artifacts. No release signing inputs are needed.
|
or securely seeded cached artifacts. No release signing inputs are needed.
|
||||||
Without native extension configuration, the existing dependency selection uses
|
Without native extension configuration, the existing dependency selection uses
|
||||||
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
|
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
|
||||||
playback-complete comparison. Inspect native libraries for diagnostic evidence;
|
playback-complete comparison. Inspect native libraries before installing.
|
||||||
do not install these direct-build APKs on the household box.
|
|
||||||
|
|
||||||
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
|
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
|
||||||
armeabi-v7a and x86_64. Keep
|
armeabi-v7a and x86_64. Keep
|
||||||
|
|
@ -345,15 +185,6 @@ not the working tree. An uncommitted merge still builds with the old version nam
|
||||||
Neither an upstream merge nor its validation build authorizes a release or TV
|
Neither an upstream merge nor its validation build authorizes a release or TV
|
||||||
installation.
|
installation.
|
||||||
|
|
||||||
## Upstream v1.1.1 integration
|
|
||||||
|
|
||||||
The patch release makes manual Seerr setup available when the optional companion
|
|
||||||
plugin is absent: setup is marked ready immediately rather than remaining pending.
|
|
||||||
With the plugin installed, an empty settings response or a failed settings read
|
|
||||||
still reaches the existing ready-state fallback; plugin URL prefill is unchanged.
|
|
||||||
There are no new Room migrations (schema remains 41), player, layout, session,
|
|
||||||
DNS/client, dependency, branding or signing changes relative to v1.1.0.
|
|
||||||
|
|
||||||
## Wolfskin shell
|
## Wolfskin shell
|
||||||
|
|
||||||
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
|
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
|
||||||
|
|
@ -812,18 +643,24 @@ retain the approved frames except for the requested feedback changes:
|
||||||
that may still be cancelled; first mount restores the intended shelf item.
|
that may still be cancelled; first mount restores the intended shelf item.
|
||||||
Discover uses all six approved poster slots. Person keeps the final column
|
Discover uses all six approved poster slots. Person keeps the final column
|
||||||
at full poster width.
|
at full poster width.
|
||||||
- Episode images choose episode Primary, then Thumb, then the already-read
|
- Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
|
||||||
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
|
Title episode cards now use 448×280 px, preserving the taller approved crop.
|
||||||
Episode context uses the episode overview, with two summary lines on the
|
Focused summaries ellipsise to one line, the show-name overlay is absent, and
|
||||||
focused card. Held-series headers measure their copy/actions and leave 40 px
|
`S1 E3 · duration` plus the watched check share one line.
|
||||||
before seasons instead of reserving a fixed empty 900 px header.
|
Ready title headers measure their copy/actions and leave 40 px before the
|
||||||
|
following section. Missing readiness lines collapse completely on every title
|
||||||
|
kind; late lines expand/collapse over the 200 ms late-line token with ease-out,
|
||||||
|
or snap with reduced motion. Already-pinned rows compensate in the same
|
||||||
|
remeasure rather than bouncing through a second focus-follow animation.
|
||||||
|
Poster-only movies floor that stack so Cast circles clear the poster by at
|
||||||
|
least 20 px, including held movie fallbacks and short overviews.
|
||||||
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
||||||
gap. Home heading geometry is unchanged.
|
gap. Home heading geometry is unchanged.
|
||||||
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
||||||
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
||||||
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
||||||
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
||||||
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
||||||
authentication query parameters remain intact. TMDB uses w342 for posters and
|
authentication query parameters remain intact. TMDB uses w342 for posters and
|
||||||
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
||||||
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
||||||
|
|
@ -875,6 +712,28 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
|
||||||
target: Down stays in nav, while Back can still leave the title. A missing rating
|
target: Down stays in nav, while Back can still leave the title. A missing rating
|
||||||
set emits no layout node, so it cannot reserve an invisible extra facts gap.
|
set emits no layout node, so it cannot reserve an invisible extra facts gap.
|
||||||
|
|
||||||
|
The title Menu replaces the old More/media-info icon action and contains Media
|
||||||
|
info plus Add for someone when requests are permitted. It is shared by held
|
||||||
|
movie, series and episode pages; non-held pages expose Add for someone in the
|
||||||
|
same menu while Request/status and Not interested/Show again stay in the row.
|
||||||
|
Version, tracks, watched and trailer controls keep their existing positions.
|
||||||
|
Back from Media info returns to the menu; Back from the menu restores its
|
||||||
|
opening button. The compact menu fades in for 160 ms and out for 100 ms using
|
||||||
|
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
|
||||||
|
bounded menu panel owns an alpha layer; the scrim is static. Opening another
|
||||||
|
sheet immediately removes the menu panel and scrim, preventing double darkening.
|
||||||
|
An empty menu closes if its last permitted action disappears. Controls
|
||||||
|
use the shared 4 px straddle focus ring. Neither series nor episode details have
|
||||||
|
a redundant Seasons shortcut; Down reaches the season chips directly. Ratings
|
||||||
|
appear only in the header facts line, not again beside progress. Explicit episode
|
||||||
|
pages use the opened episode's own set, including its `Ep` score; movie and series
|
||||||
|
pages keep their own sets.
|
||||||
|
|
||||||
|
Episode shelf skeletons use the same width, height and pitch as ready cards.
|
||||||
|
Person filmography and related-title shelves remain posters; Home continuation
|
||||||
|
tiles are a separate component and retain their series-name context.
|
||||||
|
|
||||||
|
|
||||||
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
||||||
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
||||||
adds real detail, cast, related titles, performer/filmography, readiness/receipts
|
adds real detail, cast, related titles, performer/filmography, readiness/receipts
|
||||||
|
|
@ -899,7 +758,10 @@ headers, sheets, cast and poster captions without changing shared type tokens.
|
||||||
Fractional stack placement rounds only the accumulated position, preventing a
|
Fractional stack placement rounds only the accumulated position, preventing a
|
||||||
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
|
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
|
||||||
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
|
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
|
||||||
entry, remembered columns, long-sheet visibility and full media-table geometry.
|
entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
|
||||||
|
menu Back/focus, preserved media info and household requests, movie stacking,
|
||||||
|
the Mussolini resume example, request revocation, episode skeleton geometry,
|
||||||
|
and native menu fade frames sampled in 16 ms steps.
|
||||||
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
||||||
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
||||||
maps to the player's explicit disabled sentinel, not its unspecified default.
|
maps to the player's explicit disabled sentinel, not its unspecified default.
|
||||||
|
|
@ -1118,12 +980,14 @@ Jellyfin read by the selected person's saved Jellyfin user ID, so a lost
|
||||||
Conductarr operator or a renamed household profile does not hide that user's
|
Conductarr operator or a renamed household profile does not hide that user's
|
||||||
own library. A different saved user ID still fails closed.
|
own library. A different saved user ID still fails closed.
|
||||||
|
|
||||||
`make test` keeps the native capture checks for rendered font sizes and visible
|
`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite.
|
||||||
surface behavior, plus generated-token drift checks. Styling uses generated
|
It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and
|
||||||
tokens rather than raw dimensions. The source-regex wording/style audit was
|
`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color
|
||||||
removed: source spelling is not a consumer-visible behavior contract.
|
constructors outside generated tokens, token font sizes below 12 sp, raw
|
||||||
`LibraryProgressiveSurfaceTest` captures the usable first chunk while the final
|
household library labels, and page-counter/navigation wording. Its negative
|
||||||
read is still suspended.
|
cases plant each violation independently. Existing native capture checks also
|
||||||
|
verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures
|
||||||
|
the usable first chunk while the final read is still suspended.
|
||||||
|
|
||||||
## Toolchain and disk use
|
## Toolchain and disk use
|
||||||
|
|
||||||
|
|
@ -1170,25 +1034,21 @@ the checkout when building on another host, for example:
|
||||||
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
|
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
|
||||||
```
|
```
|
||||||
|
|
||||||
For ordinary debug builds only, the wrapper creates a missing key once using
|
The wrapper creates a missing key once using the build image's `keytool`: RSA
|
||||||
the build image's `keytool`: RSA 2048, 10,000-day validity, alias
|
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
|
||||||
`androiddebugkey`, store/key passwords `android`, subject `CN=Android Debug`.
|
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
|
||||||
Household and optimized modes require the existing key and refuse generation.
|
file mode `0600`. Existing keys are reused, never regenerated; generation errors
|
||||||
The directory is mode `0700`, the file mode `0600`; existing keys are reused.
|
abort the build rather than falling back to an ephemeral container key.
|
||||||
Generation errors abort rather than falling back to an ephemeral container key.
|
|
||||||
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
|
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
|
||||||
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
|
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
|
||||||
Keep and back up this file across clean builds, disposable worktrees, container
|
Keep and back up this file across clean builds, disposable worktrees, container
|
||||||
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
|
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
|
||||||
are public; the private key is nevertheless the permanent household signing
|
are public; this key is for local debug/optimized builds, not release signing.
|
||||||
authority. It is distinct from the old production package's release signer.
|
|
||||||
|
|
||||||
### Preserve household TV data
|
### Preserve household TV data
|
||||||
|
|
||||||
Normal household updates use the in-app verified download and explicit Android
|
Install and update the household TV app only in place with `adb install -r`,
|
||||||
confirmation, preserving the same package and persistent signer. A separately
|
using the same package and persistent debug signer. Its certificate SHA-256 is:
|
||||||
authorized bootstrap may use operator `adb install -r`; that is not a substitute
|
|
||||||
for the in-app update acceptance walk. The certificate SHA-256 is:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d
|
182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d
|
||||||
|
|
@ -1206,10 +1066,9 @@ a **keep app data** choice during interactive uninstall. Choose it when
|
||||||
available; older Android versions ignore the attribute. This is a retention
|
available; older Android versions ignore the attribute. This is a retention
|
||||||
prompt, not an automatic backup or a guarantee against data clearing.
|
prompt, not an automatic backup or a guarantee against data clearing.
|
||||||
|
|
||||||
An operator can compare `dumpsys package <pkg>` before and after an in-place
|
For an in-place update, compare `dumpsys package <pkg>` before and after:
|
||||||
update: `firstInstallTime` must remain unchanged. The in-app service checks
|
`firstInstallTime` must remain unchanged. The guarded installer that checks
|
||||||
package, current signer, checksum and higher code; real-device timestamp and
|
the signer and this timestamp lives in the operator's tooling, not this repo.
|
||||||
retained-login proof remain a separate operator walk, not a JVM-test claim.
|
|
||||||
|
|
||||||
|
|
||||||
## Release credentials
|
## Release credentials
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,6 @@ else
|
||||||
signing_directory=$(dirname "$debug_keystore")
|
signing_directory=$(dirname "$debug_keystore")
|
||||||
mkdir -p "$signing_directory"
|
mkdir -p "$signing_directory"
|
||||||
chmod 700 "$signing_directory"
|
chmod 700 "$signing_directory"
|
||||||
if [[ ( $mode == household || $mode == optimized ) && ! -s $debug_keystore ]]; then
|
|
||||||
printf 'Household signing key is required; refusing to generate an identity\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! -e $debug_keystore ]]; then
|
if [[ ! -e $debug_keystore ]]; then
|
||||||
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
|
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
|
||||||
trap 'rm -rf -- "$temporary_directory"' EXIT
|
trap 'rm -rf -- "$temporary_directory"' EXIT
|
||||||
|
|
@ -70,15 +66,5 @@ else
|
||||||
chmod 600 "$debug_keystore"
|
chmod 600 "$debug_keystore"
|
||||||
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
|
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
|
||||||
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
|
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
|
||||||
if [[ $mode == household ]]; then
|
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
||||||
[[ -s $secrets/github-packages-token ]] || { printf 'Missing native package credential\n' >&2; exit 1; }
|
|
||||||
arguments+=(--mount "type=bind,src=$secrets/github-packages-token,dst=/run/native-token,readonly")
|
|
||||||
docker run "${arguments[@]}" "$image" bash -euc '
|
|
||||||
export ORG_GRADLE_PROJECT_WholphinExtensionsUsername=JustinZeus
|
|
||||||
export ORG_GRADLE_PROJECT_WholphinExtensionsPassword=$(cat /run/native-token)
|
|
||||||
exec ./gradlew "$@"
|
|
||||||
' bash "${gradle_arguments[@]}" "$@"
|
|
||||||
else
|
|
||||||
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,371 +0,0 @@
|
||||||
"""Exercise the release CLI against a local Forgejo-shaped HTTP server."""
|
|
||||||
|
|
||||||
import email.parser
|
|
||||||
import email.policy
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
import urllib.parse
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from tv_publish import REQUIRED, Forge
|
|
||||||
from tv_release import PACKAGE, SIGNER
|
|
||||||
|
|
||||||
|
|
||||||
class FakeForge:
|
|
||||||
"""Draft assets require authentication; anonymous reads work only after promotion."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.release: dict[str, Any] = {}
|
|
||||||
self.assets: dict[str, bytes] = {}
|
|
||||||
self.events: list[tuple[str, bool]] = []
|
|
||||||
self.corrupt_draft = False
|
|
||||||
self.corrupt_public = False
|
|
||||||
self.redirect_url: str | None = None
|
|
||||||
self.published_code: int | None = None
|
|
||||||
self.channel_manifest: dict[str, Any] = {}
|
|
||||||
self.latest_status = 200
|
|
||||||
self.release_list: list[dict[str, Any]] = []
|
|
||||||
self.latest_reads = 0
|
|
||||||
self.advance_before_promote: int | None = None
|
|
||||||
fixture = self
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def log_message(self, format: str, *args: Any) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def respond(self, value: Any, code: int = 200) -> None:
|
|
||||||
data = value if isinstance(value, bytes) else json.dumps(value).encode()
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Length", str(len(data)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(data)
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
authenticated = bool(self.headers.get("Authorization"))
|
|
||||||
fixture.events.append(("POST " + self.path, authenticated))
|
|
||||||
if not authenticated:
|
|
||||||
self.respond({}, 401)
|
|
||||||
return
|
|
||||||
data = self.rfile.read(int(self.headers["Content-Length"]))
|
|
||||||
if self.path.endswith("/releases"):
|
|
||||||
fixture.release = json.loads(data) | {"id": 17, "assets": []}
|
|
||||||
self.respond(fixture.release)
|
|
||||||
else:
|
|
||||||
name = urllib.parse.parse_qs(
|
|
||||||
urllib.parse.urlsplit(self.path).query
|
|
||||||
)["name"][0]
|
|
||||||
message = email.parser.BytesParser(
|
|
||||||
policy=email.policy.default
|
|
||||||
).parsebytes(
|
|
||||||
b"Content-Type: "
|
|
||||||
+ self.headers["Content-Type"].encode()
|
|
||||||
+ b"\r\n\r\n"
|
|
||||||
+ data
|
|
||||||
)
|
|
||||||
fixture.assets[name] = next(message.iter_parts()).get_payload(
|
|
||||||
decode=True
|
|
||||||
)
|
|
||||||
asset = {
|
|
||||||
"name": name,
|
|
||||||
"browser_download_url": fixture.base + "/assets/" + name,
|
|
||||||
"size": len(fixture.assets[name]),
|
|
||||||
}
|
|
||||||
fixture.release["assets"].append(asset)
|
|
||||||
self.respond(asset)
|
|
||||||
|
|
||||||
def do_PATCH(self) -> None:
|
|
||||||
fixture.events.append(
|
|
||||||
("PATCH", bool(self.headers.get("Authorization")))
|
|
||||||
)
|
|
||||||
fixture.release.update(
|
|
||||||
json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
||||||
)
|
|
||||||
self.respond(fixture.release)
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
authenticated = bool(self.headers.get("Authorization"))
|
|
||||||
fixture.events.append(("GET " + self.path, authenticated))
|
|
||||||
if fixture.redirect_url:
|
|
||||||
self.send_response(302)
|
|
||||||
self.send_header("Location", fixture.redirect_url)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
if self.path.startswith("/assets/"):
|
|
||||||
if fixture.release["draft"] and not authenticated:
|
|
||||||
self.respond({}, 404)
|
|
||||||
return
|
|
||||||
data = fixture.assets[self.path.removeprefix("/assets/")]
|
|
||||||
corrupt = (
|
|
||||||
fixture.corrupt_draft
|
|
||||||
if fixture.release["draft"]
|
|
||||||
else fixture.corrupt_public
|
|
||||||
)
|
|
||||||
self.respond(data + b"corrupt" if corrupt else data)
|
|
||||||
elif self.path == "/channel/wolfskin-update.json":
|
|
||||||
self.respond(
|
|
||||||
fixture.channel_manifest
|
|
||||||
| {"versionCode": fixture.published_code}
|
|
||||||
)
|
|
||||||
elif self.path.endswith("/latest"):
|
|
||||||
fixture.latest_reads += 1
|
|
||||||
if (
|
|
||||||
fixture.latest_reads == 2
|
|
||||||
and fixture.advance_before_promote is not None
|
|
||||||
):
|
|
||||||
fixture.published_code = fixture.advance_before_promote
|
|
||||||
if fixture.latest_status != 200:
|
|
||||||
self.respond({}, fixture.latest_status)
|
|
||||||
elif fixture.release and not fixture.release["draft"]:
|
|
||||||
self.respond(fixture.release)
|
|
||||||
elif fixture.published_code is not None:
|
|
||||||
self.respond(
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "wolfskin-update.json",
|
|
||||||
"browser_download_url": fixture.base
|
|
||||||
+ "/channel/wolfskin-update.json",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.respond({}, 404)
|
|
||||||
elif urllib.parse.urlsplit(self.path).path.endswith("/releases"):
|
|
||||||
self.respond(fixture.release_list)
|
|
||||||
else:
|
|
||||||
self.respond(fixture.release)
|
|
||||||
|
|
||||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
||||||
self.base = f"http://127.0.0.1:{self.server.server_port}"
|
|
||||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
||||||
self.thread.start()
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Release fixture resources after each independent publication scenario."""
|
|
||||||
self.server.shutdown()
|
|
||||||
self.server.server_close()
|
|
||||||
self.thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
class PublishTest(unittest.TestCase):
|
|
||||||
"""Permanent tests assert safety transitions and real uploaded bytes, not script wording."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.temporary = tempfile.TemporaryDirectory()
|
|
||||||
self.directory = Path(self.temporary.name)
|
|
||||||
self.forge = FakeForge()
|
|
||||||
self.addCleanup(self.forge.close)
|
|
||||||
self.addCleanup(self.temporary.cleanup)
|
|
||||||
assets = []
|
|
||||||
for abi in ("armeabi-v7a", "arm64-v8a", "x86_64", "universal"):
|
|
||||||
name = "Wolfskin.apk" if abi == "universal" else f"Wolfskin-{abi}.apk"
|
|
||||||
data = f"fixture APK bytes for {abi}".encode()
|
|
||||||
(self.directory / name).write_bytes(data)
|
|
||||||
assets.append(
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"abi": abi,
|
|
||||||
"bytes": len(data),
|
|
||||||
"sha256": hashlib.sha256(data).hexdigest(),
|
|
||||||
"minSdk": 23,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
manifest = {
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"packageId": PACKAGE,
|
|
||||||
"versionName": "1.1.1",
|
|
||||||
"versionCode": 100001,
|
|
||||||
"publishedAt": "2026-11-07T12:00:00Z",
|
|
||||||
"sourceCommit": "a" * 40,
|
|
||||||
"sourceTag": "pwolfskin-opt-v1.1.1",
|
|
||||||
"signerSha256": SIGNER,
|
|
||||||
"assets": assets,
|
|
||||||
}
|
|
||||||
self.forge.channel_manifest = manifest
|
|
||||||
(self.directory / "wolfskin-update.json").write_text(json.dumps(manifest))
|
|
||||||
(self.directory / "RELEASE-NOTES.md").write_text("Fixture release notes")
|
|
||||||
self.checksums()
|
|
||||||
|
|
||||||
def checksums(self) -> None:
|
|
||||||
"""Hash fixture files after deliberately constructing or mutating the bundle."""
|
|
||||||
(self.directory / "SHA256SUMS").write_text(
|
|
||||||
"".join(
|
|
||||||
f"{hashlib.sha256((self.directory / name).read_bytes()).hexdigest()} {name}\n"
|
|
||||||
for name in sorted(REQUIRED - {"SHA256SUMS"})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def cli(self, *flags: str) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Drive the real CLI and real git credential-helper protocol with a fake credential."""
|
|
||||||
environment = os.environ | {
|
|
||||||
"GIT_CONFIG_COUNT": "2",
|
|
||||||
"GIT_CONFIG_KEY_0": "credential.helper",
|
|
||||||
"GIT_CONFIG_VALUE_0": "",
|
|
||||||
"GIT_CONFIG_KEY_1": "credential.helper",
|
|
||||||
"GIT_CONFIG_VALUE_1": "!printf 'username=fixture\npassword=fixture-secret\n'",
|
|
||||||
}
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
str(Path(__file__).with_name("tv_publish.py")),
|
|
||||||
"--directory",
|
|
||||||
str(self.directory),
|
|
||||||
"--test-server",
|
|
||||||
self.forge.base,
|
|
||||||
*flags,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=environment,
|
|
||||||
timeout=30,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
self.assertNotIn("fixture-secret", result.stdout + result.stderr)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def test_dry_run_never_requests_or_fetches_credentials(self) -> None:
|
|
||||||
result = self.cli("--dry-run", "--promote")
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
self.assertEqual([], self.forge.events)
|
|
||||||
|
|
||||||
def test_draft_then_explicit_promotion_reverifies_all_bytes(self) -> None:
|
|
||||||
result = self.cli()
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
self.assertTrue(self.forge.release["draft"])
|
|
||||||
self.assertEqual(REQUIRED, set(self.forge.assets))
|
|
||||||
self.assertTrue(
|
|
||||||
all(
|
|
||||||
not auth
|
|
||||||
for path, auth in self.forge.events
|
|
||||||
if "/latest" in path or "?limit=" in path
|
|
||||||
)
|
|
||||||
)
|
|
||||||
result = self.cli("--draft-id", "17", "--promote")
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
self.assertFalse(self.forge.release["draft"])
|
|
||||||
for name, data in self.forge.assets.items():
|
|
||||||
self.assertEqual((self.directory / name).read_bytes(), data)
|
|
||||||
|
|
||||||
def test_corrupt_uploaded_bytes_remain_draft(self) -> None:
|
|
||||||
self.forge.corrupt_draft = True
|
|
||||||
result = self.cli("--promote")
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertTrue(self.forge.release["draft"])
|
|
||||||
self.assertFalse(any(event == "PATCH" for event, _ in self.forge.events))
|
|
||||||
|
|
||||||
def test_failed_anonymous_verification_withdraws_promoted_release(self) -> None:
|
|
||||||
self.forge.corrupt_public = True
|
|
||||||
result = self.cli("--promote")
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertTrue(self.forge.release["draft"])
|
|
||||||
self.assertEqual(2, sum(event == "PATCH" for event, _ in self.forge.events))
|
|
||||||
|
|
||||||
def test_invalid_local_checksum_never_creates_draft(self) -> None:
|
|
||||||
(self.directory / "Wolfskin.apk").write_bytes(b"tampered")
|
|
||||||
result = self.cli()
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual([], self.forge.events)
|
|
||||||
|
|
||||||
def test_manifest_size_mismatch_never_creates_draft(self) -> None:
|
|
||||||
path = self.directory / "wolfskin-update.json"
|
|
||||||
manifest = json.loads(path.read_text())
|
|
||||||
manifest["assets"][0]["bytes"] += 1
|
|
||||||
path.write_text(json.dumps(manifest))
|
|
||||||
self.checksums()
|
|
||||||
result = self.cli()
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual([], self.forge.events)
|
|
||||||
|
|
||||||
def test_empty_manifest_asset_set_never_creates_draft(self) -> None:
|
|
||||||
path = self.directory / "wolfskin-update.json"
|
|
||||||
manifest = json.loads(path.read_text())
|
|
||||||
manifest["assets"] = []
|
|
||||||
path.write_text(json.dumps(manifest))
|
|
||||||
self.checksums()
|
|
||||||
result = self.cli()
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual([], self.forge.events)
|
|
||||||
|
|
||||||
def test_credential_helper_tracing_is_disabled(self) -> None:
|
|
||||||
trace = self.directory / "credential.trace"
|
|
||||||
with patch.dict(os.environ, {"GIT_TRACE": str(trace)}):
|
|
||||||
result = self.cli()
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
self.assertFalse(trace.exists())
|
|
||||||
|
|
||||||
def test_equal_and_lower_bundle_codes_never_create_a_draft(self) -> None:
|
|
||||||
for published_code in (100001, 100002):
|
|
||||||
with self.subTest(published_code=published_code):
|
|
||||||
self.forge.published_code = published_code
|
|
||||||
result = self.cli("--promote")
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual({}, self.forge.release)
|
|
||||||
self.assertTrue(all(not auth for _, auth in self.forge.events))
|
|
||||||
|
|
||||||
def test_newer_bundle_reads_live_manifest_anonymously(self) -> None:
|
|
||||||
self.forge.published_code = 100000
|
|
||||||
result = self.cli()
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
self.assertTrue(self.forge.release["draft"])
|
|
||||||
live_reads = [
|
|
||||||
auth
|
|
||||||
for path, auth in self.forge.events
|
|
||||||
if path.endswith("/latest") or path.startswith("GET /channel/")
|
|
||||||
]
|
|
||||||
self.assertTrue(live_reads)
|
|
||||||
self.assertFalse(any(live_reads))
|
|
||||||
|
|
||||||
def test_channel_advance_during_upload_prevents_promotion(self) -> None:
|
|
||||||
self.forge.advance_before_promote = 100001
|
|
||||||
result = self.cli("--promote")
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertTrue(self.forge.release["draft"])
|
|
||||||
self.assertFalse(any(path == "PATCH" for path, _ in self.forge.events))
|
|
||||||
|
|
||||||
def test_empty_channel_404_permits_first_draft(self) -> None:
|
|
||||||
result = self.cli()
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
self.assertTrue(self.forge.release["draft"])
|
|
||||||
self.assertIn(
|
|
||||||
(
|
|
||||||
"GET /api/v1/repos/justinzeus/wolfskin-tv-updates/releases?limit=1",
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
self.forge.events,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_missing_latest_with_nonempty_channel_is_not_first_release(self) -> None:
|
|
||||||
self.forge.release_list = [{"id": 9}]
|
|
||||||
result = self.cli()
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual({}, self.forge.release)
|
|
||||||
|
|
||||||
def test_refused_live_channel_never_creates_draft(self) -> None:
|
|
||||||
self.forge.latest_status = 403
|
|
||||||
result = self.cli()
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual({}, self.forge.release)
|
|
||||||
|
|
||||||
def test_authenticated_redirect_never_reaches_another_origin(self) -> None:
|
|
||||||
other = FakeForge()
|
|
||||||
self.addCleanup(other.close)
|
|
||||||
self.forge.redirect_url = other.base + "/asset"
|
|
||||||
forge = Forge(self.forge.base, "Basic fixture-authorization", test_server=True)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
forge.verify(self.forge.base + "/redirect", "0" * 64, authenticated=True)
|
|
||||||
self.assertEqual([], other.events)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
"""Exercise source cleanliness, monotonic codes and fail-closed signing prerequisites."""
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import tv_release
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseInputTest(unittest.TestCase):
|
|
||||||
"""Use a disposable Git fixture, never a build or a real signing key."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.temporary = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self.temporary.cleanup)
|
|
||||||
self.root = Path(self.temporary.name) / "source"
|
|
||||||
self.root.mkdir()
|
|
||||||
(self.root / "docker").mkdir()
|
|
||||||
(self.root / "docker/tv-version-codes").write_text("65 installed-opt\n")
|
|
||||||
self.git("init", "--quiet")
|
|
||||||
self.git("add", "docker/tv-version-codes")
|
|
||||||
self.git(
|
|
||||||
"-c",
|
|
||||||
"user.name=Fixture",
|
|
||||||
"-c",
|
|
||||||
"user.email=fixture@example.invalid",
|
|
||||||
"commit",
|
|
||||||
"--quiet",
|
|
||||||
"-m",
|
|
||||||
"Record used code",
|
|
||||||
)
|
|
||||||
self.remote = Path(self.temporary.name) / "origin.git"
|
|
||||||
subprocess.run(
|
|
||||||
["git", "init", "--bare", "--quiet", str(self.remote)], check=True
|
|
||||||
)
|
|
||||||
self.git("remote", "add", "origin", str(self.remote))
|
|
||||||
self.git("push", "--quiet", "origin", "HEAD:main")
|
|
||||||
self.previous_root = tv_release.ROOT
|
|
||||||
tv_release.ROOT = self.root
|
|
||||||
self.addCleanup(setattr, tv_release, "ROOT", self.previous_root)
|
|
||||||
|
|
||||||
def git(self, *args: str) -> str:
|
|
||||||
"""Run the isolated source fixture's actual Git commands."""
|
|
||||||
return subprocess.check_output(["git", *args], cwd=self.root, text=True).strip()
|
|
||||||
|
|
||||||
def test_new_code_identifies_clean_source(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
self.git("rev-parse", "HEAD"), tv_release.validate_inputs("1.1.1", 100001)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_reused_and_lower_codes_are_rejected(self) -> None:
|
|
||||||
for code in (1, 64, 65):
|
|
||||||
with self.subTest(code=code), self.assertRaises(ValueError):
|
|
||||||
tv_release.validate_inputs("1.1.1", code)
|
|
||||||
|
|
||||||
def test_dirty_source_is_rejected(self) -> None:
|
|
||||||
(self.root / "untracked").write_text("not part of the source commit")
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
tv_release.validate_inputs("1.1.1", 100001)
|
|
||||||
|
|
||||||
def test_unparseable_version_and_invalid_codes_are_rejected(self) -> None:
|
|
||||||
for version, code in (
|
|
||||||
("1.1.1-opt", 100001),
|
|
||||||
("1.1.1", 0),
|
|
||||||
("1.1.1", 2100000001),
|
|
||||||
):
|
|
||||||
with (
|
|
||||||
self.subTest(version=version, code=code),
|
|
||||||
self.assertRaises(ValueError),
|
|
||||||
):
|
|
||||||
tv_release.validate_inputs(version, code)
|
|
||||||
|
|
||||||
def test_missing_signing_key_never_generates_identity(self) -> None:
|
|
||||||
key = Path(self.temporary.name) / "absent-key"
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}),
|
|
||||||
self.assertRaises(ValueError),
|
|
||||||
):
|
|
||||||
tv_release.release(
|
|
||||||
"1.1.1", 100001, Path(self.temporary.name) / "output", True
|
|
||||||
)
|
|
||||||
self.assertFalse(key.exists())
|
|
||||||
|
|
||||||
def test_dry_run_never_builds_tags_or_mutates_ledger(self) -> None:
|
|
||||||
key = Path(self.temporary.name) / "fixture-key-presence-only"
|
|
||||||
key.write_bytes(b"not an actual keystore; dry run must not open it")
|
|
||||||
output = Path(self.temporary.name) / "output"
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}),
|
|
||||||
contextlib.redirect_stdout(io.StringIO()),
|
|
||||||
):
|
|
||||||
tv_release.release("1.1.1", 100001, output, True)
|
|
||||||
self.assertFalse(output.exists())
|
|
||||||
self.assertEqual("", self.git("tag", "--list"))
|
|
||||||
self.assertEqual(
|
|
||||||
"65 installed-opt\n", (self.root / "docker/tv-version-codes").read_text()
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"", self.git("for-each-ref", "refs/wolfskin-tv/version-codes/")
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stale_branch_uses_fetched_main_ledger(self) -> None:
|
|
||||||
stale = self.git("rev-parse", "HEAD")
|
|
||||||
(self.root / "docker/tv-version-codes").write_text(
|
|
||||||
"65 installed-opt\n100001 released\n"
|
|
||||||
)
|
|
||||||
self.git("add", "docker/tv-version-codes")
|
|
||||||
self.git(
|
|
||||||
"-c",
|
|
||||||
"user.name=Fixture",
|
|
||||||
"-c",
|
|
||||||
"user.email=fixture@example.invalid",
|
|
||||||
"commit",
|
|
||||||
"--quiet",
|
|
||||||
"-m",
|
|
||||||
"Reserve published code",
|
|
||||||
)
|
|
||||||
self.git("push", "--quiet", "origin", "HEAD:main")
|
|
||||||
self.git("checkout", "--quiet", "--detach", stale)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
tv_release.validate_inputs("1.1.2", 100001)
|
|
||||||
self.assertEqual(stale, tv_release.validate_inputs("1.1.2", 100002))
|
|
||||||
|
|
||||||
def test_discarding_ledger_append_cannot_reuse_reserved_code(self) -> None:
|
|
||||||
commit = self.git("rev-parse", "HEAD")
|
|
||||||
tv_release.reserve_code(100001, commit)
|
|
||||||
with (self.root / "docker/tv-version-codes").open("a") as ledger:
|
|
||||||
ledger.write("100001 1.1.1 consumed\n")
|
|
||||||
self.git("restore", "docker/tv-version-codes")
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
tv_release.validate_inputs("1.1.2", 100001)
|
|
||||||
self.assertEqual(commit, tv_release.validate_inputs("1.1.2", 100002))
|
|
||||||
|
|
||||||
def test_head_reservation_wins_over_older_main(self) -> None:
|
|
||||||
(self.root / "docker/tv-version-codes").write_text(
|
|
||||||
"65 installed-opt\n100002 released\n"
|
|
||||||
)
|
|
||||||
self.git("add", "docker/tv-version-codes")
|
|
||||||
self.git(
|
|
||||||
"-c",
|
|
||||||
"user.name=Fixture",
|
|
||||||
"-c",
|
|
||||||
"user.email=fixture@example.invalid",
|
|
||||||
"commit",
|
|
||||||
"--quiet",
|
|
||||||
"-m",
|
|
||||||
"Reserve newer branch code",
|
|
||||||
)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
tv_release.validate_inputs("1.1.3", 100002)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Household org.justinvisser.wolfskin.tv.opt codes already used.
|
|
||||||
# Append successful tv-release codes; commit this ledger before another release.
|
|
||||||
65 installed-opt
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Upload a verified draft; promote only with explicit --promote owner approval."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
SERVER = "https://git.justinvisser.org"
|
|
||||||
REPOSITORY = "justinzeus/wolfskin-tv-updates"
|
|
||||||
REQUIRED = {
|
|
||||||
"Wolfskin-armeabi-v7a.apk",
|
|
||||||
"Wolfskin-arm64-v8a.apk",
|
|
||||||
"Wolfskin-x86_64.apk",
|
|
||||||
"Wolfskin.apk",
|
|
||||||
"wolfskin-update.json",
|
|
||||||
"RELEASE-NOTES.md",
|
|
||||||
"SHA256SUMS",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ForgeHttpError(RuntimeError):
|
|
||||||
"""Retain HTTP status without exposing response bodies or authorization."""
|
|
||||||
|
|
||||||
def __init__(self, method: str, status: int) -> None:
|
|
||||||
super().__init__(f"Forgejo {method} failed: HTTP {status}")
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
"""Validate every redirect before forwarding distribution-tool credentials."""
|
|
||||||
|
|
||||||
def redirect_request(
|
|
||||||
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
|
|
||||||
) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Forge:
|
|
||||||
"""Credential-scoped release API; credentials exist only in process memory."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, server: str, authorization: str, test_server: bool = False
|
|
||||||
) -> None:
|
|
||||||
self.server = server.rstrip("/")
|
|
||||||
self.origin = urllib.parse.urlsplit(self.server)
|
|
||||||
self.authorization = authorization
|
|
||||||
self.test_server = test_server
|
|
||||||
self.opener = urllib.request.build_opener(NoRedirect)
|
|
||||||
self.api = self.server + "/api/v1/repos/" + REPOSITORY
|
|
||||||
|
|
||||||
def request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
url: str,
|
|
||||||
data: Any = None,
|
|
||||||
content_type: str | None = None,
|
|
||||||
length: int | None = None,
|
|
||||||
authenticated: bool = True,
|
|
||||||
) -> Any:
|
|
||||||
"""Reject cross-origin credential forwarding and non-HTTPS production URLs."""
|
|
||||||
for _ in range(6):
|
|
||||||
parsed = urllib.parse.urlsplit(url)
|
|
||||||
local = self.test_server and parsed.hostname in (
|
|
||||||
"localhost",
|
|
||||||
"127.0.0.1",
|
|
||||||
"::1",
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
parsed.username
|
|
||||||
or parsed.password
|
|
||||||
or (parsed.scheme != "https" and not local)
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"Distribution requests require credential-free HTTPS URLs"
|
|
||||||
)
|
|
||||||
if authenticated and (parsed.scheme, parsed.netloc) != (
|
|
||||||
self.origin.scheme,
|
|
||||||
self.origin.netloc,
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"Refusing to send release credentials to another origin"
|
|
||||||
)
|
|
||||||
headers = {"Accept": "application/json"}
|
|
||||||
if authenticated:
|
|
||||||
headers["Authorization"] = self.authorization
|
|
||||||
if content_type:
|
|
||||||
headers["Content-Type"] = content_type
|
|
||||||
if length is not None:
|
|
||||||
headers["Content-Length"] = str(length)
|
|
||||||
request = urllib.request.Request(
|
|
||||||
url, data=data, method=method, headers=headers
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return self.opener.open(request, timeout=60)
|
|
||||||
except urllib.error.HTTPError as error:
|
|
||||||
if method != "GET" or error.code not in (301, 302, 303, 307, 308):
|
|
||||||
status = error.code
|
|
||||||
error.close()
|
|
||||||
raise ForgeHttpError(method, status) from None
|
|
||||||
location = error.headers.get("Location")
|
|
||||||
error.close()
|
|
||||||
if not location:
|
|
||||||
raise ValueError("Forgejo redirect has no location")
|
|
||||||
url = urllib.parse.urljoin(url, location)
|
|
||||||
raise ValueError("Too many Forgejo redirects")
|
|
||||||
|
|
||||||
def json(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
value: dict[str, Any] | None = None,
|
|
||||||
authenticated: bool = True,
|
|
||||||
) -> Any:
|
|
||||||
"""Read or mutate release metadata without exposing HTTP authorization."""
|
|
||||||
data = None if value is None else json.dumps(value).encode()
|
|
||||||
with self.request(
|
|
||||||
method,
|
|
||||||
self.api + path,
|
|
||||||
data,
|
|
||||||
"application/json",
|
|
||||||
authenticated=authenticated,
|
|
||||||
) as response:
|
|
||||||
return json.load(response)
|
|
||||||
|
|
||||||
def upload(self, release_id: int, file: Path) -> dict[str, Any]:
|
|
||||||
"""Stream multipart APK bytes instead of copying a universal APK into memory."""
|
|
||||||
boundary = uuid.uuid4().hex
|
|
||||||
prefix = (
|
|
||||||
f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{file.name}"\r\n'
|
|
||||||
"Content-Type: application/octet-stream\r\n\r\n"
|
|
||||||
).encode()
|
|
||||||
suffix = f"\r\n--{boundary}--\r\n".encode()
|
|
||||||
|
|
||||||
def chunks() -> Iterable[bytes]:
|
|
||||||
yield prefix
|
|
||||||
with file.open("rb") as stream:
|
|
||||||
while chunk := stream.read(1024 * 1024):
|
|
||||||
yield chunk
|
|
||||||
yield suffix
|
|
||||||
|
|
||||||
url = f"{self.api}/releases/{release_id}/assets?name={urllib.parse.quote(file.name)}"
|
|
||||||
with self.request(
|
|
||||||
"POST",
|
|
||||||
url,
|
|
||||||
chunks(),
|
|
||||||
f"multipart/form-data; boundary={boundary}",
|
|
||||||
len(prefix) + file.stat().st_size + len(suffix),
|
|
||||||
) as response:
|
|
||||||
return json.load(response)
|
|
||||||
|
|
||||||
def verify(self, url: str, expected: str, authenticated: bool) -> None:
|
|
||||||
"""Verify bytes fetched from Forgejo, not only its declared asset size."""
|
|
||||||
with self.request("GET", url, authenticated=authenticated) as response:
|
|
||||||
actual = hashlib.file_digest(response, "sha256").hexdigest()
|
|
||||||
if actual != expected:
|
|
||||||
raise ValueError("Uploaded artifact checksum mismatch")
|
|
||||||
|
|
||||||
def require_newer_code(self, code: int) -> None:
|
|
||||||
"""Read the public channel anonymously and fail closed on an uncertain floor."""
|
|
||||||
try:
|
|
||||||
release = self.json("GET", "/releases/latest", authenticated=False)
|
|
||||||
except ForgeHttpError as error:
|
|
||||||
if error.status != 404:
|
|
||||||
raise
|
|
||||||
releases = self.json("GET", "/releases?limit=1", authenticated=False)
|
|
||||||
if releases == []:
|
|
||||||
return
|
|
||||||
raise ValueError("Latest is missing but the channel is not empty") from None
|
|
||||||
assets = [
|
|
||||||
asset
|
|
||||||
for asset in release["assets"]
|
|
||||||
if asset["name"] == "wolfskin-update.json"
|
|
||||||
]
|
|
||||||
if len(assets) != 1:
|
|
||||||
raise ValueError("Latest release must have exactly one update manifest")
|
|
||||||
with self.request(
|
|
||||||
"GET", assets[0]["browser_download_url"], authenticated=False
|
|
||||||
) as response:
|
|
||||||
manifest = json.load(response)
|
|
||||||
latest_code = manifest["versionCode"]
|
|
||||||
if (
|
|
||||||
manifest["schemaVersion"] != 1
|
|
||||||
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
|
|
||||||
or type(latest_code) is not int
|
|
||||||
or not 1 <= latest_code <= 2100000000
|
|
||||||
):
|
|
||||||
raise ValueError("Invalid live household version identity")
|
|
||||||
if code <= latest_code:
|
|
||||||
raise ValueError(
|
|
||||||
f"Bundle versionCode must exceed live channel code {latest_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def credential(server: str) -> str:
|
|
||||||
"""Read git's existing credential helper without secrets in argv, stdout or disk."""
|
|
||||||
parsed = urllib.parse.urlsplit(server)
|
|
||||||
environment = {
|
|
||||||
name: value
|
|
||||||
for name, value in os.environ.items()
|
|
||||||
if not name.startswith("GIT_TRACE")
|
|
||||||
and name not in {"GIT_CURL_VERBOSE", "SHELLOPTS", "BASHOPTS", "BASH_ENV", "ENV"}
|
|
||||||
}
|
|
||||||
environment["GIT_TERMINAL_PROMPT"] = "0"
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "credential", "fill"],
|
|
||||||
input=f"protocol={parsed.scheme}\nhost={parsed.netloc}\n\n",
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
env=environment,
|
|
||||||
)
|
|
||||||
if result.returncode:
|
|
||||||
raise RuntimeError("Git credential helper failed; no release created")
|
|
||||||
fields = dict(
|
|
||||||
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
|
||||||
)
|
|
||||||
if not fields.get("password"):
|
|
||||||
raise ValueError("Git credential helper returned no release credential")
|
|
||||||
encoded = base64.b64encode(
|
|
||||||
f"{fields.get('username', '')}:{fields['password']}".encode()
|
|
||||||
).decode()
|
|
||||||
return "Basic " + encoded
|
|
||||||
|
|
||||||
|
|
||||||
def validate_bundle(directory: Path) -> tuple[dict[str, Any], dict[str, str]]:
|
|
||||||
"""Require every public asset and verify the local final-byte checksum set."""
|
|
||||||
for name in REQUIRED:
|
|
||||||
if not (directory / name).is_file():
|
|
||||||
raise ValueError(f"Required release asset missing: {name}")
|
|
||||||
hashes: dict[str, str] = {}
|
|
||||||
for line in (directory / "SHA256SUMS").read_text().splitlines():
|
|
||||||
match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9_.-]+)", line)
|
|
||||||
if match is None or match[2] in hashes:
|
|
||||||
raise ValueError("Invalid or duplicate SHA256SUMS entry")
|
|
||||||
hashes[match[2]] = match[1]
|
|
||||||
if set(hashes) != REQUIRED - {"SHA256SUMS"}:
|
|
||||||
raise ValueError("Checksum set must cover exactly the public artifacts")
|
|
||||||
for name, expected in hashes.items():
|
|
||||||
with (directory / name).open("rb") as stream:
|
|
||||||
if hashlib.file_digest(stream, "sha256").hexdigest() != expected:
|
|
||||||
raise ValueError(f"Local checksum mismatch: {name}")
|
|
||||||
hashes["SHA256SUMS"] = hashlib.sha256(
|
|
||||||
(directory / "SHA256SUMS").read_bytes()
|
|
||||||
).hexdigest()
|
|
||||||
manifest = json.loads((directory / "wolfskin-update.json").read_text())
|
|
||||||
if (
|
|
||||||
manifest["schemaVersion"] != 1
|
|
||||||
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
|
|
||||||
):
|
|
||||||
raise ValueError("Wrong household manifest identity")
|
|
||||||
if (
|
|
||||||
manifest["signerSha256"]
|
|
||||||
!= "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
|
|
||||||
):
|
|
||||||
raise ValueError("Wrong household signing authority")
|
|
||||||
version = manifest["versionName"]
|
|
||||||
code = manifest["versionCode"]
|
|
||||||
if (
|
|
||||||
not re.fullmatch(r"\d+\.\d+\.\d+", version)
|
|
||||||
or type(code) is not int
|
|
||||||
or not 1 <= code <= 2100000000
|
|
||||||
):
|
|
||||||
raise ValueError("Invalid household version identity")
|
|
||||||
if (
|
|
||||||
not re.fullmatch(r"[0-9a-f]{40}", manifest["sourceCommit"])
|
|
||||||
or manifest["sourceTag"] != f"pwolfskin-opt-v{version}"
|
|
||||||
):
|
|
||||||
raise ValueError("Invalid immutable source identity")
|
|
||||||
published = datetime.datetime.fromisoformat(
|
|
||||||
manifest["publishedAt"].replace("Z", "+00:00")
|
|
||||||
)
|
|
||||||
if published.utcoffset() is None:
|
|
||||||
raise ValueError("Publication time must include a timezone")
|
|
||||||
expected_abis = {"armeabi-v7a", "arm64-v8a", "x86_64", "universal"}
|
|
||||||
if (
|
|
||||||
len(manifest["assets"]) != 4
|
|
||||||
or {asset["abi"] for asset in manifest["assets"]} != expected_abis
|
|
||||||
):
|
|
||||||
raise ValueError("Manifest must describe all four standalone APKs")
|
|
||||||
for asset in manifest["assets"]:
|
|
||||||
expected_name = (
|
|
||||||
"Wolfskin.apk"
|
|
||||||
if asset["abi"] == "universal"
|
|
||||||
else f"Wolfskin-{asset['abi']}.apk"
|
|
||||||
)
|
|
||||||
if asset["name"] != expected_name or asset["minSdk"] < 23:
|
|
||||||
raise ValueError("Invalid APK asset identity or minimum SDK")
|
|
||||||
path = directory / asset["name"]
|
|
||||||
if (
|
|
||||||
hashes.get(path.name) != asset["sha256"]
|
|
||||||
or path.stat().st_size != asset["bytes"]
|
|
||||||
):
|
|
||||||
raise ValueError("Manifest does not match final APK bytes")
|
|
||||||
return manifest, hashes
|
|
||||||
|
|
||||||
|
|
||||||
def publish(
|
|
||||||
directory: Path,
|
|
||||||
server: str,
|
|
||||||
promote: bool,
|
|
||||||
dry_run: bool,
|
|
||||||
test_server: bool = False,
|
|
||||||
draft_id: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Keep incomplete uploads draft; withdraw a failed promoted release from latest."""
|
|
||||||
manifest, hashes = validate_bundle(directory)
|
|
||||||
version = manifest["versionName"]
|
|
||||||
tag = f"wolfskin-opt-v{version}"
|
|
||||||
if dry_run:
|
|
||||||
print(
|
|
||||||
f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
Forge(server, "", test_server).require_newer_code(manifest["versionCode"])
|
|
||||||
forge = Forge(server, credential(server), test_server)
|
|
||||||
if draft_id is None:
|
|
||||||
release = forge.json(
|
|
||||||
"POST",
|
|
||||||
"/releases",
|
|
||||||
{
|
|
||||||
"name": f"v{version}",
|
|
||||||
"tag_name": tag,
|
|
||||||
"body": (directory / "RELEASE-NOTES.md").read_text(),
|
|
||||||
"draft": True,
|
|
||||||
"prerelease": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
release = forge.json("GET", f"/releases/{draft_id}")
|
|
||||||
if not release["draft"] or release["tag_name"] != tag:
|
|
||||||
raise ValueError("Resume target must be the matching draft release")
|
|
||||||
release_id = release["id"]
|
|
||||||
existing = {asset["name"]: asset for asset in release.get("assets", [])}
|
|
||||||
print(
|
|
||||||
f"Verifying draft release {release_id}; it stays draft on any verification failure"
|
|
||||||
)
|
|
||||||
uploaded: dict[str, str] = {}
|
|
||||||
for name, expected in hashes.items():
|
|
||||||
asset = existing.get(name) or forge.upload(release_id, directory / name)
|
|
||||||
url = asset["browser_download_url"]
|
|
||||||
forge.verify(url, expected, authenticated=True)
|
|
||||||
uploaded[name] = url
|
|
||||||
if not promote:
|
|
||||||
print(
|
|
||||||
"Draft upload verified. Publication requires owner approval and explicit --promote."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
forge.require_newer_code(manifest["versionCode"])
|
|
||||||
try:
|
|
||||||
forge.json("PATCH", f"/releases/{release_id}", {"draft": False})
|
|
||||||
latest = forge.json("GET", "/releases/latest", authenticated=False)
|
|
||||||
if latest["id"] != release_id or latest["tag_name"] != tag:
|
|
||||||
raise ValueError("Anonymous latest does not identify the promoted release")
|
|
||||||
for name, url in uploaded.items():
|
|
||||||
forge.verify(url, hashes[name], authenticated=False)
|
|
||||||
except (ValueError, RuntimeError, OSError, KeyError, TypeError):
|
|
||||||
try:
|
|
||||||
forge.json("PATCH", f"/releases/{release_id}", {"draft": True})
|
|
||||||
except (ValueError, RuntimeError, OSError):
|
|
||||||
raise RuntimeError(
|
|
||||||
"Post-publication verification AND withdrawal failed; release may remain public"
|
|
||||||
) from None
|
|
||||||
raise RuntimeError(
|
|
||||||
"Post-publication verification failed; release withdrawn to draft"
|
|
||||||
) from None
|
|
||||||
print(f"Published and anonymously verified {tag}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Expose a deliberately separate publication command."""
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--directory", required=True, type=Path)
|
|
||||||
parser.add_argument("--promote", action="store_true")
|
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
|
||||||
parser.add_argument(
|
|
||||||
"--draft-id", type=int, help="Resume and reverify an existing matching draft"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--test-server", help="Local fake Forgejo only; HTTP loopback permitted"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.test_server:
|
|
||||||
parsed = urllib.parse.urlsplit(args.test_server)
|
|
||||||
if parsed.scheme != "http" or parsed.hostname not in (
|
|
||||||
"localhost",
|
|
||||||
"127.0.0.1",
|
|
||||||
"::1",
|
|
||||||
):
|
|
||||||
parser.error("--test-server is restricted to an HTTP loopback fixture")
|
|
||||||
publish(
|
|
||||||
args.directory.resolve(),
|
|
||||||
args.test_server or SERVER,
|
|
||||||
args.promote,
|
|
||||||
args.dry_run,
|
|
||||||
bool(args.test_server),
|
|
||||||
args.draft_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Build and inspect the household assembly without creating a signing identity."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PACKAGE = "org.justinvisser.wolfskin.tv.opt"
|
|
||||||
SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
|
|
||||||
ABIS = ("armeabi-v7a", "arm64-v8a", "x86_64", "universal")
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def run(*arguments: str) -> str:
|
|
||||||
"""Run a checked command whose arguments contain no credentials."""
|
|
||||||
return subprocess.check_output(arguments, cwd=ROOT, text=True).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def digest(path: Path) -> str:
|
|
||||||
"""Hash final artifact bytes without loading an APK into memory."""
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def validate_inputs(version: str, code: int) -> str:
|
|
||||||
"""Require a clean immutable source and a code newer than the tracked history."""
|
|
||||||
if not re.fullmatch(r"\d+\.\d+\.\d+", version) or not 1 <= code <= 2100000000:
|
|
||||||
raise ValueError("Supply a plain VERSION and a positive Android VERSION_CODE")
|
|
||||||
if run("git", "status", "--porcelain"):
|
|
||||||
raise ValueError(
|
|
||||||
"Release source must be clean, including the version-code ledger"
|
|
||||||
)
|
|
||||||
commit = run("git", "rev-parse", "HEAD")
|
|
||||||
run(
|
|
||||||
"git",
|
|
||||||
"fetch",
|
|
||||||
"--no-tags",
|
|
||||||
"origin",
|
|
||||||
"refs/heads/main:refs/remotes/origin/main",
|
|
||||||
)
|
|
||||||
used = []
|
|
||||||
for reference in (commit, "refs/remotes/origin/main"):
|
|
||||||
if run("git", "ls-tree", "--name-only", reference, "docker/tv-version-codes"):
|
|
||||||
ledger = run("git", "show", f"{reference}:docker/tv-version-codes")
|
|
||||||
used.extend(
|
|
||||||
int(line.split()[0])
|
|
||||||
for line in ledger.splitlines()
|
|
||||||
if line and not line.startswith("#")
|
|
||||||
)
|
|
||||||
reservations = run(
|
|
||||||
"git",
|
|
||||||
"for-each-ref",
|
|
||||||
"--format=%(refname:strip=3)",
|
|
||||||
"refs/wolfskin-tv/version-codes/",
|
|
||||||
)
|
|
||||||
used.extend(int(value) for value in reservations.splitlines())
|
|
||||||
if code <= max(used):
|
|
||||||
raise ValueError(f"VERSION_CODE must exceed reserved maximum {max(used)}")
|
|
||||||
if list((ROOT / "app/libs").glob("*.aar")):
|
|
||||||
raise ValueError("Local AAR overrides are forbidden for household releases")
|
|
||||||
return commit
|
|
||||||
|
|
||||||
|
|
||||||
def reserve_code(code: int, commit: str) -> None:
|
|
||||||
"""Keep a consumed code reserved even if its working-tree ledger append is discarded."""
|
|
||||||
run("git", "update-ref", f"refs/wolfskin-tv/version-codes/{code}", commit, "")
|
|
||||||
|
|
||||||
|
|
||||||
def inspect_apk(path: Path, version: str, code: int, abi: str, proof: Path) -> int:
|
|
||||||
"""Verify identity, final signature, optimization and playback-complete native inventory."""
|
|
||||||
image = os.environ.get("WOLFSKIN_BUILD_IMAGE", "wolfskin-build:sdk37-jdk21")
|
|
||||||
mount = f"type=bind,src={path.parent},dst=/artifacts,readonly"
|
|
||||||
base = (
|
|
||||||
"docker",
|
|
||||||
"run",
|
|
||||||
"--rm",
|
|
||||||
"--platform",
|
|
||||||
"linux/amd64",
|
|
||||||
"--mount",
|
|
||||||
mount,
|
|
||||||
image,
|
|
||||||
)
|
|
||||||
badging = run(*base, "aapt2", "dump", "badging", f"/artifacts/{path.name}")
|
|
||||||
certificate = run(
|
|
||||||
*base,
|
|
||||||
"apksigner",
|
|
||||||
"verify",
|
|
||||||
"--verbose",
|
|
||||||
"--print-certs",
|
|
||||||
f"/artifacts/{path.name}",
|
|
||||||
)
|
|
||||||
proof.write_text(badging + "\n" + certificate + "\n")
|
|
||||||
package = re.search(
|
|
||||||
r"package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'", badging
|
|
||||||
)
|
|
||||||
if package is None or package.groups() != (PACKAGE, str(code), version):
|
|
||||||
raise ValueError(f"Wrong APK identity: {path.name}")
|
|
||||||
fingerprints = re.findall(
|
|
||||||
r"Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)", certificate
|
|
||||||
)
|
|
||||||
if fingerprints != [SIGNER]:
|
|
||||||
raise ValueError(f"Wrong household signer: {path.name}")
|
|
||||||
if "application-debuggable" in badging:
|
|
||||||
raise ValueError("Household APK must not be debuggable")
|
|
||||||
minimum = re.search(r"sdkVersion:'(\d+)'", badging)
|
|
||||||
if minimum is None:
|
|
||||||
raise ValueError("APK minimum SDK is missing")
|
|
||||||
with zipfile.ZipFile(path) as archive:
|
|
||||||
names = archive.namelist()
|
|
||||||
for profile in ("assets/dexopt/baseline.prof", "assets/dexopt/baseline.profm"):
|
|
||||||
if profile not in names:
|
|
||||||
raise ValueError(f"Missing optimized profile: {profile}")
|
|
||||||
if any(
|
|
||||||
name.startswith(("assets/wolfskin-", "assets/library/")) for name in names
|
|
||||||
):
|
|
||||||
raise ValueError("Debug fixtures leaked into the household assembly")
|
|
||||||
for target in ABIS[:-1] if abi == "universal" else (abi,):
|
|
||||||
libraries = [name for name in names if name.startswith(f"lib/{target}/")]
|
|
||||||
for component in ("mpv", "ffmpeg", "av1"):
|
|
||||||
if not any(component in name.lower() for name in libraries):
|
|
||||||
raise ValueError(f"Missing native {component} for {target}")
|
|
||||||
proof.with_suffix(".inventory").write_text("\n".join(names) + "\n")
|
|
||||||
return int(minimum.group(1))
|
|
||||||
|
|
||||||
|
|
||||||
def release(version: str, code: int, output: Path, dry_run: bool) -> None:
|
|
||||||
"""Produce named APKs, inspected metadata, checksums and source-linked release notes."""
|
|
||||||
commit = validate_inputs(version, code)
|
|
||||||
tag = f"pwolfskin-opt-v{version}"
|
|
||||||
key = Path(
|
|
||||||
os.environ.get(
|
|
||||||
"WOLFSKIN_DEBUG_KEYSTORE",
|
|
||||||
"/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not key.is_file() or key.stat().st_size == 0:
|
|
||||||
raise ValueError(
|
|
||||||
"Existing household signing key required; never generate a replacement"
|
|
||||||
)
|
|
||||||
if output.exists():
|
|
||||||
raise ValueError("Release output must not exist; preserve previous proof")
|
|
||||||
if dry_run:
|
|
||||||
print(
|
|
||||||
f"Dry run: source {commit}; defaultOptimized {version} code {code}; four APKs, manifest, notes and SHA256SUMS; no build, tag or publication"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
existing = run("git", "tag", "--list", tag)
|
|
||||||
if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit:
|
|
||||||
raise ValueError("Source tag already identifies another commit")
|
|
||||||
reserve_code(code, commit)
|
|
||||||
output.mkdir(parents=True)
|
|
||||||
private = output / "private"
|
|
||||||
private.mkdir(mode=0o700)
|
|
||||||
with (private / "build.log").open("w") as log:
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"bash",
|
|
||||||
"docker/gradle.sh",
|
|
||||||
"household",
|
|
||||||
f"-PtvVersion={version}",
|
|
||||||
f"-PtvVersionCode={code}",
|
|
||||||
"assembleDefaultOptimized",
|
|
||||||
":app:dependencies",
|
|
||||||
"--configuration",
|
|
||||||
"defaultOptimizedRuntimeClasspath",
|
|
||||||
],
|
|
||||||
cwd=ROOT,
|
|
||||||
check=True,
|
|
||||||
stdout=log,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
)
|
|
||||||
assets: list[dict[str, object]] = []
|
|
||||||
for abi in ABIS:
|
|
||||||
suffix = "" if abi == "universal" else f"-{abi}"
|
|
||||||
source = (
|
|
||||||
ROOT
|
|
||||||
/ f"app/build/outputs/apk/default/optimized/Wholphin-default-optimized-{version}-{code}{suffix}.apk"
|
|
||||||
)
|
|
||||||
target = output / f"Wolfskin{suffix}.apk"
|
|
||||||
shutil.copyfile(source, target)
|
|
||||||
minimum = inspect_apk(target, version, code, abi, private / f"{abi}.inspection")
|
|
||||||
assets.append(
|
|
||||||
{
|
|
||||||
"abi": abi,
|
|
||||||
"name": target.name,
|
|
||||||
"bytes": target.stat().st_size,
|
|
||||||
"sha256": digest(target),
|
|
||||||
"minSdk": minimum,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mapping = ROOT / "app/build/outputs/mapping/defaultOptimized"
|
|
||||||
shutil.copytree(mapping, private / "mapping")
|
|
||||||
manifest = {
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"packageId": PACKAGE,
|
|
||||||
"versionName": version,
|
|
||||||
"versionCode": code,
|
|
||||||
"publishedAt": datetime.datetime.now(datetime.UTC)
|
|
||||||
.isoformat()
|
|
||||||
.replace("+00:00", "Z"),
|
|
||||||
"sourceCommit": commit,
|
|
||||||
"sourceTag": tag,
|
|
||||||
"signerSha256": SIGNER,
|
|
||||||
"assets": assets,
|
|
||||||
}
|
|
||||||
(output / "wolfskin-update.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
|
||||||
source = f"https://git.justinvisser.org/justinzeus/wolfskin/src/tag/{tag}"
|
|
||||||
(output / "RELEASE-NOTES.md").write_text(
|
|
||||||
f"# Wolfskin TV {version}\n\nIn-app verified household updates with explicit Android confirmation.\n\n"
|
|
||||||
f"Package `{PACKAGE}`, code {code}, signer SHA-256 `{SIGNER}`.\n"
|
|
||||||
"Same-package, same-signer upgrades retain app data; never uninstall or clear data to update. "
|
|
||||||
"An earlier ephemeral signer needs a separately authorized migration, not an automatic reinstall.\n\n"
|
|
||||||
"Android 6 (API 23) or later. Standalone armeabi-v7a, arm64-v8a and x86_64 APKs; "
|
|
||||||
"universal fallback. 32-bit x86 is not MPV-complete and is unsupported for playback. "
|
|
||||||
"MPV, ffmpeg and AV1 extensions: pinned Wholphin extensions 0.2.4.\n\n"
|
|
||||||
f"GPL-2.0-only; Wolfskin derives from Wholphin by damontecres and contributors. "
|
|
||||||
f"Corresponding source: [{commit}]({source}); "
|
|
||||||
f"[source archive](https://git.justinvisser.org/justinzeus/wolfskin/archive/{tag}.tar.gz).\n"
|
|
||||||
"[Upstream Wholphin](https://github.com/damontecres/wholphin), "
|
|
||||||
"[native extension source 0.2.4](https://github.com/damontecres/wholphin-extensions/tree/v0.2.4).\n"
|
|
||||||
)
|
|
||||||
files = sorted(path for path in output.iterdir() if path.is_file())
|
|
||||||
(output / "SHA256SUMS").write_text(
|
|
||||||
"".join(f"{digest(path)} {path.name}\n" for path in files)
|
|
||||||
)
|
|
||||||
if not existing:
|
|
||||||
run("git", "tag", tag, commit)
|
|
||||||
with (ROOT / "docker/tv-version-codes").open("a") as ledger:
|
|
||||||
ledger.write(f"{code} {version} {commit}\n")
|
|
||||||
print(
|
|
||||||
f"Release packaged at {output}; commit the code ledger and push source tag {tag} before distribution. Nothing published."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Expose the make tv-release build contract."""
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--version", required=True)
|
|
||||||
parser.add_argument("--version-code", required=True, type=int)
|
|
||||||
parser.add_argument("--output", type=Path, required=True)
|
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
release(args.version, args.version_code, args.output.resolve(), args.dry_run)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -19,23 +19,18 @@ android {
|
||||||
}
|
}
|
||||||
kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11
|
kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11
|
||||||
|
|
||||||
|
val robolectricSdk by configurations.creating {
|
||||||
|
isCanBeResolved = true
|
||||||
|
isCanBeConsumed = false
|
||||||
|
isTransitive = false
|
||||||
|
}
|
||||||
val cacheRobolectricSdk by tasks.registering(Copy::class) {
|
val cacheRobolectricSdk by tasks.registering(Copy::class) {
|
||||||
val versions = listOf(
|
from(robolectricSdk)
|
||||||
"9-robolectric-4913185-2-i7",
|
|
||||||
"11-robolectric-6757853-i7",
|
|
||||||
"14-robolectric-10818077-i7",
|
|
||||||
)
|
|
||||||
// One configuration would evict the older SDKs through version conflict resolution.
|
|
||||||
from(versions.map { version ->
|
|
||||||
project.configurations.detachedConfiguration(
|
|
||||||
project.dependencies.create("org.robolectric:android-all-instrumented:$version"),
|
|
||||||
).apply { isTransitive = false }
|
|
||||||
})
|
|
||||||
into(layout.buildDirectory.dir("robolectric-sdk"))
|
into(layout.buildDirectory.dir("robolectric-sdk"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val checkTokenDrift by tasks.registering {
|
val checkTokenDrift by tasks.registering {
|
||||||
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||||
doLast {
|
doLast {
|
||||||
file("tokens.sha256").readLines().forEach { line ->
|
file("tokens.sha256").readLines().forEach { line ->
|
||||||
val (expected, name) = line.split(" ", limit = 2)
|
val (expected, name) = line.split(" ", limit = 2)
|
||||||
|
|
@ -55,6 +50,9 @@ tasks.withType<Test>().configureEach {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7")
|
||||||
|
robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7")
|
||||||
|
robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7")
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.tv.material)
|
implementation(libs.androidx.tv.material)
|
||||||
|
|
|
||||||
|
|
@ -121,10 +121,9 @@ def main() -> None:
|
||||||
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
|
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
|
||||||
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
|
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
|
||||||
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
|
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
|
||||||
tokens.update(json.loads((ROOT / "update-tokens.json").read_text()))
|
|
||||||
kotlin, css = render_tokens(tokens)
|
kotlin, css = render_tokens(tokens)
|
||||||
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
||||||
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py"]
|
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"]
|
||||||
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
|
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
|
||||||
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
|
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
|
||||||
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
|
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
DejaVu Sans and DejaVu Sans Bold
|
|
||||||
https://dejavu-fonts.github.io/
|
|
||||||
|
|
||||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
|
||||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
|
||||||
DejaVu changes are in public domain.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of the fonts accompanying this license ("Fonts") and associated
|
|
||||||
documentation files (the "Font Software"), to reproduce and distribute the
|
|
||||||
Font Software, including without limitation the rights to use, copy, merge,
|
|
||||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
|
||||||
persons to whom the Font Software is furnished to do so, subject to the
|
|
||||||
following conditions:
|
|
||||||
|
|
||||||
The above copyright and trademark notices and this permission notice shall
|
|
||||||
be included in all copies of one or more of the Font Software typefaces.
|
|
||||||
|
|
||||||
The Font Software may be modified, altered, or added to, and in particular
|
|
||||||
the designs of glyphs or characters in the Fonts may be modified and
|
|
||||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
|
||||||
are renamed to names not containing either the words "Bitstream" or the word
|
|
||||||
"Vera".
|
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts or Font
|
|
||||||
Software that has been modified and is distributed under the "Bitstream
|
|
||||||
Vera" names.
|
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but no
|
|
||||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
|
||||||
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
|
||||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
|
||||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
|
||||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
|
||||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
|
||||||
FONT SOFTWARE.
|
|
||||||
|
|
||||||
Except as contained in this notice, the names of Gnome, the Gnome
|
|
||||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
|
||||||
otherwise to promote the sale, use or other dealings in this Font Software
|
|
||||||
without prior written authorization from the Gnome Foundation or Bitstream
|
|
||||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
|
||||||
org.
|
|
||||||
|
|
@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
|
||||||
|
|
||||||
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
||||||
@Composable
|
@Composable
|
||||||
fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
|
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
|
||||||
val exact = remember { CssColumnHeight() }
|
val exact = remember { CssColumnHeight() }
|
||||||
Layout(content, modifier.then(exact)) { children, constraints ->
|
Layout(content, modifier.then(exact)) { children, constraints ->
|
||||||
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
|
import coil3.request.transitionFactory
|
||||||
|
import coil3.transition.Transition
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
|
||||||
|
|
@ -50,13 +52,14 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
||||||
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
||||||
}) else null }
|
}) else null }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, poster, dim) {
|
val heroReady = LocalHeroArtReady.current
|
||||||
|
val request = remember(context, path, poster, dim, heroReady != null) {
|
||||||
val upright = poster || dim
|
val upright = poster || dim
|
||||||
WolfskinImages.request(context, path,
|
WolfskinImages.request(context, path,
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
||||||
|
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
|
||||||
}
|
}
|
||||||
val heroReady = LocalHeroArtReady.current
|
|
||||||
if (painter != null) {
|
if (painter != null) {
|
||||||
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||||
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -47,7 +49,10 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
val focus = remember(state) { object : ShellContentFocus by state {} }
|
val focus = remember(state) { object : ShellContentFocus by state {} }
|
||||||
DisposableEffect(focus) {
|
DisposableEffect(focus) {
|
||||||
shell.contentFocus = focus
|
shell.contentFocus = focus
|
||||||
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
|
onDispose {
|
||||||
|
if (shell.tabs.getOrNull(shell.currentTab) != "discover") state.resetLaneMemory()
|
||||||
|
if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
|
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
|
||||||
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
||||||
|
|
@ -103,6 +108,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
|
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
HeroMotionScope {
|
||||||
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
||||||
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
|
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
|
||||||
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
|
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
|
||||||
|
|
@ -129,13 +135,15 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscoverHero(state: DiscoverState) {
|
private fun DiscoverHero(state: DiscoverState) {
|
||||||
val title = state.hero ?: return
|
val title = state.hero ?: return
|
||||||
HeroCopyHandoff(title.key, title) { copy ->
|
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
HeroCopyHandoff(title.key, title) { copy ->
|
||||||
|
Column {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
||||||
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
||||||
|
|
@ -151,17 +159,32 @@ private fun DiscoverHero(state: DiscoverState) {
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(D.heroMetaGap))
|
Spacer(Modifier.height(D.heroMetaGap))
|
||||||
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(D.heroOverviewGap))
|
Spacer(Modifier.height(D.heroOverviewGap))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
||||||
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
||||||
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) }
|
state.heroes.forEachIndexed { index, _ -> DiscoverHeroDot(index, index == state.slide) }
|
||||||
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pager geometry retargets from its current value independently of artwork loading. */
|
||||||
|
@Composable
|
||||||
|
private fun DiscoverHeroDot(index: Int, selected: Boolean) {
|
||||||
|
val progress = remember { Animatable(if (selected) 1f else 0f) }
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
LaunchedEffect(selected, reduced) {
|
||||||
|
progress.animateTo(if (selected) 1f else 0f,
|
||||||
|
tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||||
}
|
}
|
||||||
|
val widthProgress = if (reduced) { if (selected) 1f else 0f } else progress.value
|
||||||
|
Box(Modifier.testTag("hero-dot-$index").size(D.dotSize + (D.dotActive - D.dotSize) * widthProgress, D.dotSize)
|
||||||
|
.background(C.fg.copy(alpha = .3f + .7f * progress.value), CircleShape))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
val height = if (services) K.entryH else K.posterH
|
val height = if (services) K.entryH else K.posterH
|
||||||
val cards = rememberLazyListState()
|
val cards = rememberLazyListState()
|
||||||
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
||||||
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
val column = state.laneColumn(lane.id).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||||
val active = state.contentFocused && state.focusedRow == row
|
val active = state.contentFocused && state.focusedRow == row
|
||||||
val slots = K.posterSlots
|
val slots = K.posterSlots
|
||||||
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
val first = state.laneFirsts[lane.id] ?: 0
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(column, count, active) {
|
LaunchedEffect(state.service?.id, column, count, active) {
|
||||||
val target = shelfFirst(column, first, slots, count)
|
val target = shelfFirst(column, first, slots, count)
|
||||||
state.shell.view.cards["first:${lane.id}"] = target
|
state.laneFirsts[lane.id] = target
|
||||||
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
|
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
/** One person owns a Discover view; the shell retains its root list and card memory. */
|
/** Each person retains the root viewport; lane movement belongs only to this Discover visit. */
|
||||||
class DiscoverState(
|
class DiscoverState(
|
||||||
val shell: ShellState,
|
val shell: ShellState,
|
||||||
initial: DiscoverSnapshot,
|
initial: DiscoverSnapshot,
|
||||||
|
|
@ -75,6 +75,20 @@ class DiscoverState(
|
||||||
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
|
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
|
||||||
private var rootFilter = "all"
|
private var rootFilter = "all"
|
||||||
|
|
||||||
|
private val rootLaneMemory = DiscoverLaneMemory()
|
||||||
|
private val serviceLaneMemory = mutableMapOf<Long, DiscoverLaneMemory>()
|
||||||
|
private val laneMemory get() = service?.let { serviceLaneMemory.getValue(it.id) } ?: rootLaneMemory
|
||||||
|
private val laneColumns get() = laneMemory.columns
|
||||||
|
internal val laneFirsts get() = laneMemory.firsts
|
||||||
|
|
||||||
|
internal fun laneColumn(id: String): Int = laneColumns[id] ?: 0
|
||||||
|
|
||||||
|
/** Pushed titles preserve this visit; selecting another root tab discards lane movement. */
|
||||||
|
internal fun resetLaneMemory() {
|
||||||
|
rootLaneMemory.clear()
|
||||||
|
serviceLaneMemory.values.forEach { it.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
fun updateSnapshot(value: DiscoverSnapshot) {
|
fun updateSnapshot(value: DiscoverSnapshot) {
|
||||||
snapshot = value
|
snapshot = value
|
||||||
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
|
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
|
||||||
|
|
@ -139,7 +153,7 @@ class DiscoverState(
|
||||||
val rowCount: Int get() = firstShelf + lanes.size
|
val rowCount: Int get() = firstShelf + lanes.size
|
||||||
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
|
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
|
||||||
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
||||||
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
|
val column: Int get() = (if (focusedRow >= firstShelf) laneColumn(rowKey) else shell.view.cards[rowKey].orZero()).coerceAtMost(maxColumn())
|
||||||
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
|
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
|
||||||
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
|
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
|
||||||
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
||||||
|
|
@ -152,7 +166,8 @@ class DiscoverState(
|
||||||
|
|
||||||
fun focus(row: Int, column: Int = 0) {
|
fun focus(row: Int, column: Int = 0) {
|
||||||
shell.navFocused = false; shell.view.row = row
|
shell.navFocused = false; shell.view.row = row
|
||||||
shell.view.cards[rowKey] = column.coerceIn(0, maxColumn())
|
val selected = column.coerceIn(0, maxColumn())
|
||||||
|
if (focusedRow >= firstShelf) laneColumns[rowKey] = selected else shell.view.cards[rowKey] = selected
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveRow(direction: Int) {
|
private fun moveRow(direction: Int) {
|
||||||
|
|
@ -161,10 +176,7 @@ class DiscoverState(
|
||||||
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
||||||
if (target >= rowCount) return
|
if (target >= rowCount) return
|
||||||
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
||||||
val nearest = if (focusedRow >= firstShelf && target >= firstShelf)
|
focus(target, if (target >= firstShelf) laneColumn(key) else shell.view.cards[key].orZero())
|
||||||
column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero()
|
|
||||||
else 0
|
|
||||||
focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openTitle(item: DiscoverTitle) {
|
fun openTitle(item: DiscoverTitle) {
|
||||||
|
|
@ -225,12 +237,14 @@ class DiscoverState(
|
||||||
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
|
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
|
||||||
|
|
||||||
fun openService(item: DiscoverService) {
|
fun openService(item: DiscoverService) {
|
||||||
|
serviceLaneMemory.getOrPut(item.id) { DiscoverLaneMemory() }
|
||||||
rootFilter = filter; service = item; filter = "movie"; revision++
|
rootFilter = filter; service = item; filter = "movie"; revision++
|
||||||
onPush("discover-service/${item.id}")
|
onPush("discover-service/${item.id}")
|
||||||
focus(firstShelf)
|
focus(firstShelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enterRoute(route: String) {
|
fun enterRoute(route: String) {
|
||||||
|
if (route in shell.tabs && route != "discover") resetLaneMemory()
|
||||||
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
|
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
|
||||||
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
|
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +275,7 @@ class DiscoverState(
|
||||||
ShellKey.Left, ShellKey.Right -> {
|
ShellKey.Left, ShellKey.Right -> {
|
||||||
val direction = if (key == ShellKey.Left) -1 else 1
|
val direction = if (key == ShellKey.Left) -1 else 1
|
||||||
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
|
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
|
||||||
|
else if (focusedRow >= firstShelf) laneColumns[rowKey] = (column + direction).coerceIn(0, maxColumn())
|
||||||
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
|
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
|
||||||
}
|
}
|
||||||
ShellKey.Ok -> when {
|
ShellKey.Ok -> when {
|
||||||
|
|
@ -417,6 +432,12 @@ class DiscoverState(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class DiscoverLaneMemory {
|
||||||
|
val columns = mutableStateMapOf<String, Int>()
|
||||||
|
val firsts = mutableStateMapOf<String, Int>()
|
||||||
|
fun clear() { columns.clear(); firsts.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun Int?.orZero(): Int = this ?: 0
|
private fun Int?.orZero(): Int = this ?: 0
|
||||||
|
|
||||||
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")
|
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ import androidx.tv.material3.Icon
|
||||||
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
val resource = when (name) {
|
val resource = when (name) {
|
||||||
"gear" -> R.drawable.wolfskin_gear
|
"gear" -> R.drawable.wolfskin_gear
|
||||||
"updates" -> R.drawable.wolfskin_update
|
|
||||||
"settings-list" -> R.drawable.wolfskin_settings_list
|
|
||||||
"settings-right" -> R.drawable.wolfskin_settings_chevron
|
|
||||||
"requests" -> R.drawable.wolfskin_tray
|
"requests" -> R.drawable.wolfskin_tray
|
||||||
"interface" -> R.drawable.wolfskin_grid
|
"interface" -> R.drawable.wolfskin_grid
|
||||||
"play", "playback" -> R.drawable.wolfskin_play
|
"play", "playback" -> R.drawable.wolfskin_play
|
||||||
|
|
|
||||||
|
|
@ -6,95 +6,156 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.foundation.layout.wrapContentSize
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||||
import kotlinx.coroutines.launch
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import kotlinx.coroutines.coroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
|
||||||
/** Artwork success or its final fallback releases a pending hero hand-off. */
|
/** Artwork success or its final fallback releases a pending hero hand-off. */
|
||||||
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
|
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
internal val HeroLayerAlpha = SemanticsPropertyKey<Float>("HeroLayerAlpha")
|
||||||
|
internal val HeroLayerTranslation = SemanticsPropertyKey<Float>("HeroLayerTranslation")
|
||||||
|
internal val HeroLayerIdentity = SemanticsPropertyKey<String>("HeroLayerIdentity")
|
||||||
|
|
||||||
/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */
|
private class HeroPresentation {
|
||||||
|
var identity by mutableStateOf<Any?>(null)
|
||||||
|
}
|
||||||
|
private val LocalHeroPresentation = compositionLocalOf<HeroPresentation?> { null }
|
||||||
|
|
||||||
|
/** Artwork and copy share readiness without coupling either layer to a screen's selection state. */
|
||||||
|
@Composable
|
||||||
|
internal fun HeroMotionScope(content: @Composable () -> Unit) {
|
||||||
|
val presentation = remember { HeroPresentation() }
|
||||||
|
CompositionLocalProvider(LocalHeroPresentation provides presentation, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HeroLayer<T>(val identity: Any, var target: T, initialAlpha: Float) {
|
||||||
|
val alpha = Animatable(initialAlpha)
|
||||||
|
var ready by mutableStateOf(false)
|
||||||
|
var presented = false
|
||||||
|
var outgoing by mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HeroCopyTarget<T>(val identity: Any, var target: T)
|
||||||
|
|
||||||
|
/** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||||
var displayed by remember { mutableStateOf(target) }
|
val initial = remember { HeroLayer(identity, target, 1f).apply { presented = true } }
|
||||||
var displayedIdentity by remember { mutableStateOf(identity) }
|
val layers = remember { mutableStateListOf(initial) }
|
||||||
var outgoing by remember { mutableStateOf<T?>(null) }
|
val selected = remember(identity) { layers.firstOrNull { it.identity == identity && it.presented && !it.outgoing } ?: HeroLayer(identity, target, 1f) }
|
||||||
val alpha = remember { Animatable(0f) }
|
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
var readyIdentity by remember { mutableStateOf(identity) }
|
val presentation = LocalHeroPresentation.current
|
||||||
val latestIdentity by rememberUpdatedState(identity)
|
LaunchedEffect(selected, reduced) {
|
||||||
val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } }
|
layers.removeAll { it !== selected && !it.presented }
|
||||||
LaunchedEffect(identity, reduced) {
|
if (selected !in layers) layers.add(0, selected)
|
||||||
if (displayedIdentity != identity) {
|
snapshotFlow { selected.ready }.first { it }
|
||||||
outgoing = displayed
|
selected.presented = true
|
||||||
alpha.snapTo(1f)
|
presentation?.identity = identity
|
||||||
snapshotFlow { readyIdentity == identity }.first { it }
|
layers.forEach { if (it !== selected) it.outgoing = true }
|
||||||
displayed = target
|
|
||||||
displayedIdentity = identity
|
|
||||||
}
|
|
||||||
if (outgoing != null) {
|
|
||||||
alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
|
|
||||||
outgoing = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) }
|
layers.forEach { layer -> key(layer) {
|
||||||
outgoing?.let { old ->
|
SideEffect { if (layer.identity == identity) layer.target = target }
|
||||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) {
|
LaunchedEffect(layer.outgoing, reduced) {
|
||||||
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) }
|
if (layer.outgoing) {
|
||||||
|
layer.alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
|
||||||
|
layers.remove(layer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
val outgoing = layer.outgoing
|
||||||
|
val onReady = remember(layer) { { layer.ready = true } }
|
||||||
|
val bounds = if (outgoing) Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height) else Modifier
|
||||||
|
Box(bounds.graphicsLayer { alpha = layer.alpha.value; clip = outgoing }
|
||||||
|
.testTag(if (outgoing) "hero-outgoing" else "hero-incoming")
|
||||||
|
.semantics { this[HeroLayerAlpha] = layer.alpha.value; this[HeroLayerTranslation] = 0f; this[HeroLayerIdentity] = layer.identity.toString() }) {
|
||||||
|
CompositionLocalProvider(LocalHeroArtReady provides onReady) {
|
||||||
|
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
|
||||||
|
content(if (layer.identity == identity) target else layer.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */
|
/** One copy slot fades through after artwork readiness; no two titles can be painted together. */
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||||
val reduced = reducedMotion()
|
var entered by rememberSaveable { mutableStateOf(false) }
|
||||||
val alpha = remember { Animatable(0f) }
|
val selected = remember(identity) { HeroCopyTarget(identity, target) }
|
||||||
val progress = remember { Animatable(0f) }
|
val currentSelected by rememberUpdatedState(selected)
|
||||||
var displayed by remember { mutableStateOf(target) }
|
var displayed by remember { mutableStateOf(selected) }
|
||||||
var previous by remember { mutableStateOf(identity) }
|
val alpha = remember { Animatable(if (entered) 1f else 0f) }
|
||||||
|
val progress = remember { Animatable(if (entered) 1f else 0f) }
|
||||||
|
var initialEntry by remember { mutableStateOf(!entered) }
|
||||||
var handingOff by remember { mutableStateOf(false) }
|
var handingOff by remember { mutableStateOf(false) }
|
||||||
var entered by remember { mutableStateOf(false) }
|
var outgoing by remember { mutableStateOf(false) }
|
||||||
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() }
|
val reduced = reducedMotion()
|
||||||
LaunchedEffect(identity, reduced) {
|
val presentation = LocalHeroPresentation.current
|
||||||
if (previous != identity || handingOff) {
|
val rise = with(LocalDensity.current) { (if (initialEntry) M.copyEntryRise else M.copyRise).toPx() }
|
||||||
handingOff = true
|
SideEffect { selected.target = target }
|
||||||
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
|
LaunchedEffect(presentation, reduced) {
|
||||||
displayed = target
|
snapshotFlow { currentSelected.takeIf { presentation == null || presentation.identity == it.identity } }
|
||||||
previous = identity
|
.filterNotNull()
|
||||||
progress.snapTo(0f)
|
.distinctUntilChangedBy { it.identity }
|
||||||
if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong())
|
.collectLatest { next ->
|
||||||
kotlinx.coroutines.coroutineScope {
|
if (!entered) {
|
||||||
launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
displayed = next
|
||||||
launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
entered = true
|
||||||
|
} else if (displayed.identity != next.identity || handingOff) {
|
||||||
|
handingOff = true
|
||||||
|
outgoing = true
|
||||||
|
coroutineScope {
|
||||||
|
val fade = if (alpha.value > 0f) launch {
|
||||||
|
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
|
||||||
|
} else null
|
||||||
|
if (!reduced) kotlinx.coroutines.delay(M.heroCopyDelay.toLong())
|
||||||
|
fade?.join()
|
||||||
|
}
|
||||||
|
outgoing = false
|
||||||
|
displayed = next
|
||||||
|
initialEntry = false
|
||||||
|
progress.snapTo(0f)
|
||||||
|
}
|
||||||
|
if (alpha.value != 1f || progress.value != 1f) coroutineScope {
|
||||||
|
val duration = motionDuration(if (initialEntry) M.rowEnter else M.heroCopyIn, reduced)
|
||||||
|
launch { alpha.animateTo(1f, tween(duration, easing = M.easeOut)) }
|
||||||
|
launch { progress.animateTo(1f, tween(duration, easing = M.easeOut)) }
|
||||||
|
}
|
||||||
|
handingOff = false
|
||||||
}
|
}
|
||||||
handingOff = false
|
|
||||||
} else if (!entered) {
|
|
||||||
kotlinx.coroutines.coroutineScope {
|
|
||||||
launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
|
||||||
launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entered = true
|
|
||||||
displayed = target
|
|
||||||
}
|
|
||||||
Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) {
|
|
||||||
content(if (handingOff || previous != identity) displayed else target)
|
|
||||||
}
|
}
|
||||||
|
Box(modifier.graphicsLayer {
|
||||||
|
this.alpha = alpha.value
|
||||||
|
translationY = if (reduced) 0f else rise * (1f - progress.value)
|
||||||
|
}.testTag(if (outgoing) "hero-copy-outgoing" else "hero-copy").semantics {
|
||||||
|
this[HeroLayerAlpha] = alpha.value
|
||||||
|
this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - progress.value)
|
||||||
|
this[HeroLayerIdentity] = displayed.identity.toString()
|
||||||
|
}) { content(if (displayed.identity == identity) target else displayed.target) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
|
||||||
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||||
|
HeroMotionScope {
|
||||||
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
||||||
if (!row.skeleton) {
|
if (!row.skeleton) {
|
||||||
val title = row.cards.first()
|
val title = row.cards.first()
|
||||||
|
|
@ -28,7 +29,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
}
|
}
|
||||||
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
||||||
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
||||||
HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy ->
|
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
|
||||||
|
HeroCopyHandoff(title.id, title) { copy ->
|
||||||
Column {
|
Column {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
||||||
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
||||||
|
|
@ -44,6 +46,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(H.factsBottom))
|
Spacer(Modifier.height(H.factsBottom))
|
||||||
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(H.overviewBottom))
|
Spacer(Modifier.height(H.overviewBottom))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
||||||
|
|
@ -52,7 +56,6 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
|
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
|
||||||
|
|
@ -64,6 +67,7 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discover continues in the same scroller, under the active person's identity. */
|
/** Discover continues in the same scroller, under the active person's identity. */
|
||||||
|
|
|
||||||
|
|
@ -21,24 +21,19 @@ fun personColor(key: String): Color = when (key.lowercase()) {
|
||||||
else -> WolfskinTokens.Color.accent
|
else -> WolfskinTokens.Color.accent
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates has its own locked avatar swatch; other destinations retain household colours. */
|
|
||||||
fun resolveUpdateAvatarColor(person: ShellPerson): Color =
|
|
||||||
if (person.key.equals("justin", ignoreCase = true)) WolfskinTokens.Update.avatarJustin else personColor(person.key)
|
|
||||||
|
|
||||||
/** The same mark anchors the picker and the persistent navigation. */
|
/** The same mark anchors the picker and the persistent navigation. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) {
|
fun Wordmark(modifier: Modifier = Modifier) {
|
||||||
Row(modifier) {
|
Row(modifier) {
|
||||||
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle)
|
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
|
||||||
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle)
|
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initials are drawn over opaque person colour at the exact slot size. */
|
/** Initials are drawn over opaque person colour at the exact slot size. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus,
|
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) {
|
||||||
backgroundColor: Color = personColor(person.key)) {
|
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
|
||||||
Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) {
|
|
||||||
Text(person.initial, color = letterColor, style = textStyle)
|
Text(person.initial, color = letterColor, style = textStyle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,15 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
|
||||||
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
||||||
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
||||||
|
|
||||||
fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox> {
|
fun navBoxes(tabs: List<String>): List<NavBox> {
|
||||||
val updates = WolfskinTokens.Update
|
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
|
||||||
var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap
|
|
||||||
return tabs.map { tab ->
|
return tabs.map { tab ->
|
||||||
val width = when (tab) {
|
val width = when (tab) {
|
||||||
"home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth
|
"home" -> S.navHomeWidth
|
||||||
"discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth
|
"discover" -> S.navDiscoverWidth
|
||||||
"movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth
|
"movies" -> S.navMoviesWidth
|
||||||
"shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth
|
"shows" -> S.navShowsWidth
|
||||||
else -> if (isUpdatesLayout) updates.navAnimeWidth else S.navAnimeWidth
|
else -> S.navAnimeWidth
|
||||||
}
|
}
|
||||||
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
|
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
|
||||||
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
|
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
|
||||||
|
|
@ -58,8 +57,7 @@ fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox>
|
||||||
/** There is one blob, including when focus wraps around the nav avatar. */
|
/** There is one blob, including when focus wraps around the nav avatar. */
|
||||||
@Composable
|
@Composable
|
||||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||||
val hasSelectedTab = state.route != "settings" && state.route != "updates"
|
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||||
val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) }
|
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
|
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
|
||||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||||
|
|
@ -81,15 +79,14 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
||||||
Canvas(Modifier.matchParentSize()) {
|
Canvas(Modifier.matchParentSize()) {
|
||||||
if (scrolled) drawRect(C.bg) else drawRect(band)
|
if (scrolled) drawRect(C.bg) else drawRect(band)
|
||||||
if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||||
}
|
}
|
||||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf",
|
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
||||||
if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark)
|
|
||||||
state.tabs.forEachIndexed { itemIndex, tab ->
|
state.tabs.forEachIndexed { itemIndex, tab ->
|
||||||
val item = boxes[itemIndex]
|
val item = boxes[itemIndex]
|
||||||
val focused = state.navFocused && state.navIndex == itemIndex
|
val focused = state.navFocused && state.navIndex == itemIndex
|
||||||
val labelColor by animateColorAsState(
|
val labelColor by animateColorAsState(
|
||||||
if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted,
|
if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
|
||||||
animationSpec = labelFade,
|
animationSpec = labelFade,
|
||||||
label = "nav-label-$tab",
|
label = "nav-label-$tab",
|
||||||
)
|
)
|
||||||
|
|
@ -98,8 +95,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
.clickable {
|
.clickable {
|
||||||
state.focusNav(itemIndex); state.key(ShellKey.Ok)
|
state.focusNav(itemIndex); state.key(ShellKey.Ok)
|
||||||
}, contentAlignment = Alignment.Center) {
|
}, contentAlignment = Alignment.Center) {
|
||||||
Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor)
|
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
|
||||||
if (hasSelectedTab && itemIndex == state.currentTab && !focused) {
|
if (itemIndex == state.currentTab && !focused) {
|
||||||
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
|
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,11 +111,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val avatar = boxes.last()
|
val avatar = boxes.last()
|
||||||
Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav,
|
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||||
Modifier.offset(avatar.left, avatar.top)
|
Modifier.offset(avatar.left, avatar.top)
|
||||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||||
.focusProperties { canFocus = false }
|
.focusProperties { canFocus = false }
|
||||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) },
|
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
||||||
backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -35,53 +36,50 @@ import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||||
import org.justinvisser.wolfskin.design.UpdateTypography as T
|
|
||||||
|
|
||||||
/** Settings routes identify real upstream destinations rather than stand-in panels. */
|
/** Settings routes identify real upstream destinations rather than stand-in panels. */
|
||||||
data class SettingsGroup(val key: String, val title: String, val description: String)
|
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
|
||||||
|
|
||||||
val SETTINGS_GROUPS = listOf(
|
val SETTINGS_GROUPS = listOf(
|
||||||
SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
|
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
|
||||||
SettingsGroup("updates", "Updates", "Check and install"),
|
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
|
||||||
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
|
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
|
||||||
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
|
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
|
||||||
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
|
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
|
||||||
SettingsGroup("profile", "User profile settings", "Protection · Home · Language"),
|
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
|
||||||
SettingsGroup("about", "About", "Installed version · Wolfskin about"),
|
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
|
||||||
SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"),
|
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
||||||
SettingsGroup("advanced", "Advanced settings", "Playback backend · Logs · Image cache"),
|
|
||||||
SettingsGroup("classic", "Classic settings (trial)", "Original settings, including About → Update"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The count describes returned request history, not an inferred queue of active downloads. */
|
/** The count describes returned request history, not an inferred queue of active downloads. */
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
|
||||||
val list = state.view.listState
|
val list = state.view.listState
|
||||||
val reduced = reducedMotion()
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||||
if (state.navFocused) return@LaunchedEffect
|
if (state.navFocused) return@LaunchedEffect
|
||||||
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
val y = L.row_lhead + L.row_srow * state.settingsRow
|
||||||
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
|
||||||
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
|
||||||
|
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
|
||||||
|
list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
||||||
}
|
}
|
||||||
Box(modifier.fillMaxSize()) {
|
LazyColumn(modifier.fillMaxSize(), state = list,
|
||||||
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
item(key = "settings-header") {
|
||||||
Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(state.person))
|
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
|
||||||
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
||||||
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
|
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
||||||
|
Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg)
|
||||||
|
Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop)
|
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||||
.height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)),
|
Box(Modifier.height(L.row_srow)) {
|
||||||
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
|
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
||||||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
if (group.key == "requests") requestCount else null,
|
||||||
Box(Modifier.height(L.row_srow)) {
|
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
||||||
val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group
|
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
||||||
SettingsRow(display, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
|
||||||
if (group.key == "requests") requestCount else null, Modifier.offset(L.safeSide)) {
|
|
||||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,14 +94,15 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
|
||||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||||
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
|
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
||||||
Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
||||||
Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1)
|
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
||||||
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
|
if (group.wolfskin) SettingsPill("Wolfskin", focused)
|
||||||
Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
|
||||||
|
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +122,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
|
||||||
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
|
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
|
||||||
}
|
}
|
||||||
}.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) {
|
}.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) {
|
||||||
Text(label, color = foreground, style = T.caption.copy(fontWeight = T.label.fontWeight))
|
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,13 +260,9 @@ class ShellState(
|
||||||
}
|
}
|
||||||
} else if (route == "settings") {
|
} else if (route == "settings") {
|
||||||
val group = SETTINGS_GROUPS[settingsRow].key
|
val group = SETTINGS_GROUPS[settingsRow].key
|
||||||
when (group) {
|
if (group == "requests") openRequests() else {
|
||||||
"requests" -> openRequests()
|
handOff = group
|
||||||
"updates" -> onAction(ShellAction.Destination("updates"))
|
onAction(ShellAction.HandOff(group))
|
||||||
else -> {
|
|
||||||
handOff = group
|
|
||||||
onAction(ShellAction.HandOff(group))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else contentFocus?.key(ShellKey.Ok)
|
else contentFocus?.key(ShellKey.Ok)
|
||||||
|
|
@ -309,7 +305,6 @@ class ShellState(
|
||||||
gearFocused = true
|
gearFocused = true
|
||||||
pickerIndex = people.lastIndex
|
pickerIndex = people.lastIndex
|
||||||
} else if (route !in tabs) {
|
} else if (route !in tabs) {
|
||||||
if (route == "updates") navFocused = false
|
|
||||||
onAction(ShellAction.Pop)
|
onAction(ShellAction.Pop)
|
||||||
} else if (!navFocused) {
|
} else if (!navFocused) {
|
||||||
navFocused = true
|
navFocused = true
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ fun ShellSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
handlesContentKeys: Boolean = true,
|
handlesContentKeys: Boolean = true,
|
||||||
settingsRequestCount: Int? = null,
|
settingsRequestCount: Int? = null,
|
||||||
settingsInstalledVersion: String = "",
|
|
||||||
overlay: @Composable () -> Unit = {},
|
overlay: @Composable () -> Unit = {},
|
||||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||||
) {
|
) {
|
||||||
|
|
@ -92,7 +91,7 @@ fun ShellSurface(
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} else {
|
||||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
if (state.requestsVisible) ComingInSheet(state)
|
if (state.requestsVisible) ComingInSheet(state)
|
||||||
overlay()
|
overlay()
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,13 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.transitionFactory
|
||||||
|
import coil3.transition.Transition
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
@ -54,8 +57,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
|
||||||
}
|
}
|
||||||
else if (path != null) {
|
else if (path != null) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, width, height, poster) {
|
val request = remember(context, path, width, height, poster, heroReady != null) {
|
||||||
WolfskinImages.request(context, path, width, height, poster)
|
WolfskinImages.request(context, path, width, height, poster)
|
||||||
|
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
|
||||||
}
|
}
|
||||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
|
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
|
||||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
|
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
|
||||||
|
|
@ -110,7 +114,7 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
||||||
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
||||||
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||||
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
.size(L.heroSheetWidth, L.heroSheetHeight).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxSize().background(horizontal))
|
Box(Modifier.fillMaxSize().background(horizontal))
|
||||||
Box(Modifier.fillMaxSize().background(vertical))
|
Box(Modifier.fillMaxSize().background(vertical))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
|
@ -35,6 +39,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||||
val item = state.data.item
|
val item = state.data.item
|
||||||
|
val factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item
|
||||||
|
val readinessHeight by animateDpAsState(
|
||||||
|
if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot,
|
||||||
|
tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut),
|
||||||
|
label = "title-readiness-height")
|
||||||
val episode = when {
|
val episode = when {
|
||||||
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
||||||
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
||||||
|
|
@ -56,10 +65,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
||||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings)
|
||||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) {
|
||||||
state.data.requestedStage?.let { stage ->
|
state.data.requestedStage?.let { stage ->
|
||||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -74,7 +83,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||||
}
|
}
|
||||||
state.data.playTarget?.let { target ->
|
state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (target.resumeSeconds > 0) {
|
if (target.resumeSeconds > 0) {
|
||||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||||
|
|
@ -83,7 +92,6 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
}
|
}
|
||||||
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
||||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
||||||
|
/** A compact modal keeps secondary actions reachable without growing the header row. */
|
||||||
|
@Composable
|
||||||
|
internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||||
|
val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val opacity = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(visible, reduced) {
|
||||||
|
if (visible) focus.requestFocus()
|
||||||
|
opacity.animateTo(if (visible) 1f else 0f,
|
||||||
|
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||||
|
}
|
||||||
|
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||||
|
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
||||||
|
.clickable { if (visible) state.back(shell) }
|
||||||
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
|
val key = readShellKey(event)
|
||||||
|
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
|
}.focusable()) {
|
||||||
|
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||||
|
.graphicsLayer { alpha = opacity.value }
|
||||||
|
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
|
.padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||||
|
Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading)
|
||||||
|
state.menuOptions.forEachIndexed { index, option ->
|
||||||
|
val focused = visible && state.sheetIndex == index
|
||||||
|
TitleControl(option, focused,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) {
|
||||||
|
if (visible) state.clickSheet(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ data class TitleVersion(
|
||||||
|
|
||||||
enum class TitleReadStatus { Ready, Loading, Unavailable }
|
enum class TitleReadStatus { Ready, Loading, Unavailable }
|
||||||
enum class TitleConnection { SignedIn, SignedOut, Offline }
|
enum class TitleConnection { SignedIn, SignedOut, Offline }
|
||||||
enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
enum class TitleSheet { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
||||||
|
|
||||||
/** A title read includes related titles and the selected season; reads happen outside composition. */
|
/** A title read includes related titles and the selected season; reads happen outside composition. */
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
TitleSheet.MediaInfo -> MediaInformation(state)
|
||||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
||||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
||||||
null -> Unit
|
TitleSheet.Menu, null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
val view = shell.view
|
val view = shell.view
|
||||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
||||||
// No content target exists during an initial load; nav Down must not enter a skeleton.
|
// No content target exists during an initial load; Down must not enter a skeleton.
|
||||||
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||||
else when (readShellKey(event)) {
|
else when (readShellKey(event)) {
|
||||||
ShellKey.Down -> true
|
ShellKey.Down -> true
|
||||||
|
|
@ -69,7 +69,8 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||||
}
|
}
|
||||||
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
|
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
||||||
|
TitleMenu(state, shell)
|
||||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,9 +92,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
var wheelPending by remember { mutableStateOf(false) }
|
var wheelPending by remember { mutableStateOf(false) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||||
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||||
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
val item = state.data.item
|
||||||
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop))
|
||||||
|
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||||
|
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
||||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
@ -117,8 +120,18 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val position by remember(list, rowOffsets) { derivedStateOf {
|
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||||
} }
|
} }
|
||||||
|
val headerPixels = with(density) { headerHeight.roundToPx() }
|
||||||
|
val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) }
|
||||||
|
val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress &&
|
||||||
|
list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 &&
|
||||||
|
list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 }
|
||||||
|
// Compensate in the same remeasure; an asynchronous scroll would chase the animated header.
|
||||||
|
androidx.compose.runtime.SideEffect {
|
||||||
|
if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0))
|
||||||
|
previousHeaderPixels[0] = headerPixels
|
||||||
|
}
|
||||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
if (compensateHeader) return@LaunchedEffect
|
||||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||||
val y = rowOffsets[index]
|
val y = rowOffsets[index]
|
||||||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
||||||
|
|
@ -154,7 +167,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val active = state.row == id && !shell.navFocused && state.sheet == null
|
val active = state.row == id && !shell.navFocused && state.sheet == null
|
||||||
when (id) {
|
when (id) {
|
||||||
"header" -> Box(Modifier.height(headerHeight)) {
|
"header" -> Box(Modifier.height(headerHeight)) {
|
||||||
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
|
TitleHeader(state, active, if (compactHeader) Modifier.onSizeChanged { size ->
|
||||||
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
||||||
} else Modifier)
|
} else Modifier)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
LaunchedEffect(state.column, active, state.data.cast.size) {
|
LaunchedEffect(state.column, active, state.data.cast.size) {
|
||||||
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
||||||
}
|
}
|
||||||
Box(Modifier.height(D.castHeight)) {
|
Box(Modifier.height(D.castHeight).testTag("title-cast")) {
|
||||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||||
|
|
@ -80,11 +80,11 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||||
val titles = if (episodes) state.data.episodes else state.data.related
|
val titles = if (episodes) state.data.episodes else state.data.related
|
||||||
val id = if (episodes) "episodes" else "related"
|
val id = if (episodes) "episodes" else "related"
|
||||||
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
val width = if (episodes) D.episodeCardWidth else WolfskinTokens.Card.posterW
|
||||||
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
|
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
|
||||||
val track = rememberLazyListState()
|
val track = rememberLazyListState()
|
||||||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
val visible = if (episodes) ((WolfskinTokens.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() else WolfskinTokens.Card.posterSlots
|
||||||
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
val pitch = with(LocalDensity.current) { (if (episodes) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() }
|
||||||
LaunchedEffect(state.column, active, titles.size) {
|
LaunchedEffect(state.column, active, titles.size) {
|
||||||
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
||||||
}
|
}
|
||||||
|
|
@ -92,13 +92,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
||||||
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
|
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(if (episodes) WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterGap)) {
|
||||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { index ->
|
||||||
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
}
|
}
|
||||||
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||||
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
TitlePoster(item, active && state.column == index, Modifier.width(width).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier)
|
||||||
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
||||||
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||||
}
|
}
|
||||||
|
|
@ -117,17 +117,16 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
||||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
|
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
|
||||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||||
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
|
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
|
||||||
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
|
D.episodeCardWidth, D.episodeCardHeight, watched = item.watched,
|
||||||
progress = item.progress.takeIf { it < 1f } ?: 0f) {
|
progress = item.progress.takeIf { it < 1f } ?: 0f) {
|
||||||
TitleTileArtwork(item)
|
TitleTileArtwork(item)
|
||||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0)
|
||||||
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
|
|
||||||
}
|
}
|
||||||
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
|
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) {
|
||||||
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ class TitleState(
|
||||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||||
private set
|
private set
|
||||||
var sheetIndex by mutableStateOf(0)
|
var sheetIndex by mutableStateOf(0)
|
||||||
|
private var mediaInfoFromMenu = false
|
||||||
|
val menuOptions: List<TitleOption> by derivedStateOf { buildList {
|
||||||
|
if (target != null) add(TitleOption("info", "Media info", "", icon = false))
|
||||||
|
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) {
|
||||||
|
add(TitleOption("add", "Add for someone", "users", icon = false))
|
||||||
|
}
|
||||||
|
} }
|
||||||
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||||
var versionId by mutableStateOf(initial.selectedVersion)
|
var versionId by mutableStateOf(initial.selectedVersion)
|
||||||
var audioIndex by mutableStateOf(initial.selectedAudio)
|
var audioIndex by mutableStateOf(initial.selectedAudio)
|
||||||
|
|
@ -65,6 +72,7 @@ class TitleState(
|
||||||
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
||||||
if (row !in rows) { row = "header"; column = 0 }
|
if (row !in rows) { row = "header"; column = 0 }
|
||||||
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
||||||
|
if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pointer focus and D-pad use the same row memory. */
|
/** Pointer focus and D-pad use the same row memory. */
|
||||||
|
|
@ -140,7 +148,7 @@ class TitleState(
|
||||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||||
fun back(shell: ShellState) {
|
fun back(shell: ShellState) {
|
||||||
when {
|
when {
|
||||||
sheet != null -> sheet = null
|
sheet != null -> closeSheet()
|
||||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||||
else -> onAction(TitleAction.Pop)
|
else -> onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
|
|
@ -182,13 +190,12 @@ class TitleState(
|
||||||
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||||
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||||
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
||||||
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
||||||
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
|
|
||||||
"retry" -> onAction(TitleAction.Retry)
|
"retry" -> onAction(TitleAction.Retry)
|
||||||
"back" -> onAction(TitleAction.Pop)
|
"back" -> onAction(TitleAction.Pop)
|
||||||
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
"request", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
||||||
"status" -> onAction(TitleAction.Requests(data.item))
|
"status" -> onAction(TitleAction.Requests(data.item))
|
||||||
"hide", "unhide" -> {
|
"hide", "unhide" -> {
|
||||||
val hidden = id == "hide"
|
val hidden = id == "hide"
|
||||||
|
|
@ -205,9 +212,10 @@ class TitleState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sheetKey(key: ShellKey) {
|
private fun sheetKey(key: ShellKey) {
|
||||||
if (key == ShellKey.Back) { sheet = null; return }
|
if (key == ShellKey.Back) { closeSheet(); return }
|
||||||
val current = version
|
val current = version
|
||||||
val size = when (sheet) {
|
val size = when (sheet) {
|
||||||
|
TitleSheet.Menu -> menuOptions.size
|
||||||
TitleSheet.Version -> data.versions.size
|
TitleSheet.Version -> data.versions.size
|
||||||
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
||||||
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
||||||
|
|
@ -217,6 +225,16 @@ class TitleState(
|
||||||
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
||||||
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
||||||
ShellKey.Ok -> when (sheet) {
|
ShellKey.Ok -> when (sheet) {
|
||||||
|
TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option ->
|
||||||
|
if (option.id == "info") {
|
||||||
|
mediaInfoFromMenu = true
|
||||||
|
sheetIndex = 0
|
||||||
|
sheet = TitleSheet.MediaInfo
|
||||||
|
} else {
|
||||||
|
sheet = null
|
||||||
|
activateOption(option.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
TitleSheet.Version -> {
|
TitleSheet.Version -> {
|
||||||
val selected = data.versions[sheetIndex]
|
val selected = data.versions[sheetIndex]
|
||||||
versionId = selected.id
|
versionId = selected.id
|
||||||
|
|
@ -234,11 +252,19 @@ class TitleState(
|
||||||
onAction(TitleAction.Hide(data.item, false))
|
onAction(TitleAction.Hide(data.item, false))
|
||||||
} else onAction(TitleAction.Pop)
|
} else onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
else -> sheet = null
|
else -> closeSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun closeSheet() {
|
||||||
|
if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) {
|
||||||
|
mediaInfoFromMenu = false
|
||||||
|
sheetIndex = 0
|
||||||
|
sheet = TitleSheet.Menu
|
||||||
|
} else sheet = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun selectTrack(current: TitleVersion) {
|
private fun selectTrack(current: TitleVersion) {
|
||||||
when {
|
when {
|
||||||
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
||||||
|
|
@ -274,14 +300,12 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
||||||
val series = data.watchTarget.kind == "tv"
|
val series = data.watchTarget.kind == "tv"
|
||||||
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
||||||
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
||||||
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
|
if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
|
||||||
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
|
|
||||||
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false))
|
|
||||||
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
||||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||||
} else {
|
} else {
|
||||||
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
||||||
add(TitleOption("other", "For someone else", "users", icon = false))
|
add(TitleOption("menu", "Menu", "more"))
|
||||||
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
||||||
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.design
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.PlatformTextStyle
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.style.LineHeightStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
|
|
||||||
/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */
|
|
||||||
object UpdateTypography {
|
|
||||||
val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold))
|
|
||||||
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
|
||||||
private val platform = PlatformTextStyle(includeFontPadding = false)
|
|
||||||
private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform)
|
|
||||||
val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle)
|
|
||||||
val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle)
|
|
||||||
val heading = resolveStyle(WolfskinTokens.Update.heading)
|
|
||||||
val nav = resolveStyle(WolfskinTokens.Type.nav)
|
|
||||||
val wordmark = resolveStyle(WolfskinTokens.Type.wordmark)
|
|
||||||
val listTitle = resolveStyle(WolfskinTokens.Type.listTitle)
|
|
||||||
val body = resolveStyle(WolfskinTokens.Type.body)
|
|
||||||
val button = resolveStyle(WolfskinTokens.Type.button)
|
|
||||||
val label = resolveStyle(WolfskinTokens.Type.label)
|
|
||||||
val caption = resolveStyle(WolfskinTokens.Type.caption)
|
|
||||||
val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav)
|
|
||||||
val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader)
|
|
||||||
}
|
|
||||||
|
|
@ -221,9 +221,6 @@ object WolfskinTokens {
|
||||||
const val fillInverse = "background color.fg, text color.onFocus"
|
const val fillInverse = "background color.fg, text color.onFocus"
|
||||||
}
|
}
|
||||||
object Motion {
|
object Motion {
|
||||||
const val updateScan = 1800
|
|
||||||
const val updateSheen = 1600
|
|
||||||
val linear = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
|
|
||||||
const val skeletonCycle = 1400
|
const val skeletonCycle = 1400
|
||||||
const val skeletonLowAlpha = 0.55f
|
const val skeletonLowAlpha = 0.55f
|
||||||
const val focusMove = 180
|
const val focusMove = 180
|
||||||
|
|
@ -265,7 +262,7 @@ object WolfskinTokens {
|
||||||
const val heroRequestHeight = 1080
|
const val heroRequestHeight = 1080
|
||||||
const val heroPosterRequestWidth = 420
|
const val heroPosterRequestWidth = 420
|
||||||
const val heroPosterRequestHeight = 630
|
const val heroPosterRequestHeight = 630
|
||||||
const val episodeRequestWidth = 400
|
const val episodeRequestWidth = 448
|
||||||
const val episodeRequestHeight = 280
|
const val episodeRequestHeight = 280
|
||||||
const val castRequestWidth = 200
|
const val castRequestWidth = 200
|
||||||
const val castRequestHeight = 200
|
const val castRequestHeight = 200
|
||||||
|
|
@ -454,8 +451,11 @@ object WolfskinTokens {
|
||||||
const val sheetOpacity = 0.5f
|
const val sheetOpacity = 0.5f
|
||||||
}
|
}
|
||||||
object Title {
|
object Title {
|
||||||
|
val episodeCardWidth = 224.0.dp
|
||||||
val episodeCardHeight = 140.0.dp
|
val episodeCardHeight = 140.0.dp
|
||||||
|
val actionMenuWidth = 260.0.dp
|
||||||
val seriesHeaderGap = 20.0.dp
|
val seriesHeaderGap = 20.0.dp
|
||||||
|
val posterCastGap = 10.0.dp
|
||||||
val lineGap = 11.0.dp
|
val lineGap = 11.0.dp
|
||||||
val lineLift = -2.0.dp
|
val lineLift = -2.0.dp
|
||||||
val overviewGap = 17.0.dp
|
val overviewGap = 17.0.dp
|
||||||
|
|
@ -703,48 +703,4 @@ object WolfskinTokens {
|
||||||
const val ambientSaturation = 1.2f
|
const val ambientSaturation = 1.2f
|
||||||
const val ambientBrightness = 0.55f
|
const val ambientBrightness = 0.55f
|
||||||
}
|
}
|
||||||
object Update {
|
|
||||||
val avatarJustin = ComposeColor(0xFF7AB8FF)
|
|
||||||
val wordmarkWidth = 81.796875.dp
|
|
||||||
val navHomeWidth = 74.6640625.dp
|
|
||||||
val navDiscoverWidth = 99.0703125.dp
|
|
||||||
val navMoviesWidth = 85.2578125.dp
|
|
||||||
val navShowsWidth = 80.578125.dp
|
|
||||||
val navAnimeWidth = 79.234375.dp
|
|
||||||
val notesListTop = 14.0.dp
|
|
||||||
val notesListBottom = 14.0.dp
|
|
||||||
val notesMarkerOffset = 5.5.dp
|
|
||||||
val notesMarkerWidth = 14.5.dp
|
|
||||||
val heading = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 20.160000000000004.sp, letterSpacing = -0.02.em)
|
|
||||||
const val settingsVisibleRows = 6.0f
|
|
||||||
val headerTop = 75.0.dp
|
|
||||||
val headerHeight = 42.0.dp
|
|
||||||
val rowsTop = 139.0.dp
|
|
||||||
val rowsViewport = 362.0.dp
|
|
||||||
val contentTop = 149.0.dp
|
|
||||||
val width = 650.0.dp
|
|
||||||
val padding = 20.0.dp
|
|
||||||
val border = 1.0.dp
|
|
||||||
val radius = 11.0.dp
|
|
||||||
val titleBottom = 8.0.dp
|
|
||||||
val paragraphBottom = 7.0.dp
|
|
||||||
val actionsTop = 18.0.dp
|
|
||||||
val actionGap = 12.0.dp
|
|
||||||
val progressTop = 15.0.dp
|
|
||||||
val progressBottom = 11.0.dp
|
|
||||||
val progressHeight = 6.0.dp
|
|
||||||
val progressRadius = 4.0.dp
|
|
||||||
val autoTop = 431.0.dp
|
|
||||||
val toggleWidth = 40.0.dp
|
|
||||||
val toggleHeight = 21.0.dp
|
|
||||||
val toggleThumb = 15.0.dp
|
|
||||||
val toggleInset = 3.0.dp
|
|
||||||
val notesHeight = 256.0.dp
|
|
||||||
val notesGap = 9.0.dp
|
|
||||||
val notesScrollStep = 40.0.dp
|
|
||||||
val backTop = 92.0.dp
|
|
||||||
const val scanFraction = 0.28f
|
|
||||||
const val scanEnd = 4.6f
|
|
||||||
const val sheenAlpha = 0.35f
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
|
||||||
<path android:pathData="M12,6 L22,16 L12,26" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
|
||||||
<path android:pathData="M5,8 H27 M5,16 H27 M5,24 H27" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
|
||||||
<path android:pathData="M26,14 A11,11 0,1 0,25 23 M26,5 V14 H17" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
|
||||||
</vector>
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,65 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** A catalogue read has no right to move the destination's focus into the root navigation. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class DiscoverDetailsFocusTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun heroDetailsRetainsDestinationFocusDuringLoadingAndLateMetadata() {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
|
||||||
|
val snapshot = discoverTestFixture().snapshot("justin")
|
||||||
|
val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
|
||||||
|
val selected = requireNotNull(discover.hero)
|
||||||
|
val title = TitleState(TitlePageData(TitleItem(selected.key, selected.title, selected.type), status = TitleReadStatus.Loading), shell.person) {}
|
||||||
|
val art = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
|
||||||
|
val modifier = Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
|
||||||
|
if (shell.route.startsWith("title/")) TitleSurface(title, shell, modifier)
|
||||||
|
else ShellSurface(shell, modifier) { DiscoverScreen(discover, it) }
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
repeat(6) {
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertFalse("Loading cannot steal destination focus on a later frame", shell.navFocused)
|
||||||
|
}
|
||||||
|
assertEquals("title/${android.net.Uri.encode(selected.key)}", shell.route)
|
||||||
|
assertFalse("The destination owns focus even while its read is pending", shell.navFocused)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
assertFalse("Down on a loading title must not enter nav or a skeleton", shell.navFocused)
|
||||||
|
rule.runOnIdle { title.update(title.data.copy(status = TitleReadStatus.Ready)) }
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
val before = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
|
rule.runOnIdle { title.update(title.data.copy(item = title.data.item.copy(overview = "Late overview", ratings = listOf(LibraryRating("rt", 88.0))))) }
|
||||||
|
repeat(24) {
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertEquals("Loaded actions cannot replay their entry or move after late metadata", before, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
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.runtime.saveable.rememberSaveableStateHolder
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import coil3.intercept.Interceptor
|
||||||
|
import coil3.request.ErrorResult
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/** Real Discover artwork, copy and pager are sampled on every manually advanced native frame. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class DiscoverHeroSequenceTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val samples = JSONArray()
|
||||||
|
private lateinit var directory: File
|
||||||
|
private lateinit var state: DiscoverState
|
||||||
|
private lateinit var shell: ShellState
|
||||||
|
private val artAvailable = mutableStateOf(true)
|
||||||
|
private val reduced = mutableStateOf(false)
|
||||||
|
private var previousPixel: Int? = null
|
||||||
|
private val mounted = mutableStateOf(true)
|
||||||
|
private var lowerPixel = 0
|
||||||
|
private var largestJump = 0
|
||||||
|
private var previousLayers = emptyMap<String, Pair<Float, Float>>()
|
||||||
|
private val previousDotWidths = mutableMapOf<Int, Float>()
|
||||||
|
private var previousSelection: Int? = null
|
||||||
|
|
||||||
|
private fun launch(scenario: String, failedArt: Boolean = false, settle: Boolean = true) {
|
||||||
|
directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-hero/$scenario").apply { mkdirs() }
|
||||||
|
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
|
||||||
|
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
|
||||||
|
if (chain.request.data.toString().startsWith("sequence:")) {
|
||||||
|
if (failedArt) ErrorResult(image = null, request = chain.request, throwable = IOException("Controlled artwork failure")) else awaitCancellation()
|
||||||
|
} else chain.proceed()
|
||||||
|
}) }
|
||||||
|
val fixture = discoverTestFixture()
|
||||||
|
val colors = listOf(android.graphics.Color.RED, android.graphics.Color.BLUE, android.graphics.Color.GREEN)
|
||||||
|
val titles = fixture.snapshot("justin").picks.take(3).mapIndexed { index, title ->
|
||||||
|
title.copy(backdrop = "sequence:$index", poster = null, posterOnly = false, score = 100.0 - index)
|
||||||
|
}
|
||||||
|
val art = colors.map { color -> Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() }
|
||||||
|
val snapshot = fixture.snapshot("justin").copy(picks = titles)
|
||||||
|
shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
|
||||||
|
state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
val saved = rememberSaveableStateHolder()
|
||||||
|
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalDiscoverArt provides { path ->
|
||||||
|
path.removePrefix("sequence:").toIntOrNull()?.let { if (it != 1 || artAvailable.value) art[it] else null }
|
||||||
|
}) {
|
||||||
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||||
|
if (mounted.value) saved.SaveableStateProvider("discover") { DiscoverScreen(state, modifier) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
if (settle) rule.mainClock.advanceTimeBy(640) else rule.mainClock.advanceTimeByFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun initialCopyEntersOnceAndDoesNotReplayOnDestinationRemount() {
|
||||||
|
launch("initial", settle = false)
|
||||||
|
sample("initial", 0)
|
||||||
|
fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true)
|
||||||
|
.fetchSemanticsNodes().single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||||
|
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
|
||||||
|
frames("entry", 16)
|
||||||
|
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
rule.runOnIdle { mounted.value = false }
|
||||||
|
frames("absent", 1)
|
||||||
|
rule.runOnIdle { mounted.value = true }
|
||||||
|
repeat(8) { frame ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
sample("remount", frame)
|
||||||
|
assertEquals("Returning to a saved destination cannot replay its entrance", 1f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() {
|
||||||
|
launch("interrupted")
|
||||||
|
sample("settled", 0)
|
||||||
|
press(Key.DirectionRight)
|
||||||
|
frames("first", 12)
|
||||||
|
press(Key.DirectionRight)
|
||||||
|
frames("interrupted", 28)
|
||||||
|
press(Key.DirectionLeft)
|
||||||
|
frames("reverse", 28)
|
||||||
|
finish()
|
||||||
|
assertSmoothPixels()
|
||||||
|
assertEquals(1, state.slide)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun slowArtRetainsBothThePaintedHeroAndItsCopyUntilReady() {
|
||||||
|
launch("slow")
|
||||||
|
sample("settled", 0)
|
||||||
|
val original = previousPixel
|
||||||
|
val originalLower = lowerPixel
|
||||||
|
rule.runOnIdle { artAvailable.value = false }
|
||||||
|
press(Key.DirectionRight)
|
||||||
|
repeat(24) { frame ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
sample("pending", frame)
|
||||||
|
assertEquals("Pending art cannot expose a placeholder beneath the hero", originalLower, lowerPixel)
|
||||||
|
}
|
||||||
|
assertEquals("Pending art must not replace the painted backdrop", original, previousPixel)
|
||||||
|
assertEquals(state.heroes.first().key, copyIdentities().single())
|
||||||
|
rule.runOnIdle { artAvailable.value = true }
|
||||||
|
frames("ready", 28)
|
||||||
|
finish()
|
||||||
|
assertSmoothPixels()
|
||||||
|
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedArtBlendsToItsFinalFallbackWithoutErasingThePreviousHero() {
|
||||||
|
launch("failed", failedArt = true)
|
||||||
|
sample("settled", 0)
|
||||||
|
rule.runOnIdle { artAvailable.value = false }
|
||||||
|
press(Key.DirectionRight)
|
||||||
|
frames("fallback", 28)
|
||||||
|
rule.onNodeWithTag("discover-missing-art").assertExists()
|
||||||
|
finish()
|
||||||
|
assertSmoothPixels()
|
||||||
|
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun repeatedPressesRetargetTheCurrentCompositeWithoutSnapBack() {
|
||||||
|
launch("rapid")
|
||||||
|
sample("settled", 0)
|
||||||
|
listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionLeft, Key.DirectionLeft, Key.DirectionRight, Key.DirectionRight).forEachIndexed { index, key ->
|
||||||
|
press(key)
|
||||||
|
frames("press-$index", 3)
|
||||||
|
}
|
||||||
|
repeat(48) { index ->
|
||||||
|
press(if (index % 2 == 0) Key.DirectionLeft else Key.DirectionRight)
|
||||||
|
frames("burst-$index", 1)
|
||||||
|
}
|
||||||
|
frames("settled-latest", 28)
|
||||||
|
finish()
|
||||||
|
assertSmoothPixels()
|
||||||
|
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reducedMotionKeepsTheBlendButRemovesCopyTranslation() {
|
||||||
|
launch("reduced")
|
||||||
|
rule.runOnIdle { reduced.value = true }
|
||||||
|
press(Key.DirectionRight)
|
||||||
|
frames("reduced", (WolfskinTokens.Motion.heroFade + 15) / 16)
|
||||||
|
finish()
|
||||||
|
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||||
|
assertEquals(1, rule.onAllNodesWithTag("hero-incoming", useUnmergedTree = true).fetchSemanticsNodes().size)
|
||||||
|
assertTrue(rule.onAllNodesWithTag("hero-outgoing", useUnmergedTree = true).fetchSemanticsNodes().isEmpty())
|
||||||
|
val moving = (0 until samples.length()).map(samples::getJSONObject).filter { row ->
|
||||||
|
val layers = row.getJSONArray("layers")
|
||||||
|
(0 until layers.length()).map(layers::getJSONObject).any {
|
||||||
|
it.getString("kind") == "hero-outgoing" && it.getDouble("alpha") > 0.0 && it.getDouble("alpha") < 1.0
|
||||||
|
}
|
||||||
|
}.map { it.getLong("timeMs") }
|
||||||
|
assertTrue("Reduced artwork fade cannot retain the ordinary 320ms lifetime", moving.last() - moving.first() <= WolfskinTokens.Motion.reducedFade)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyIdentities() = rule.onAllNodesWithTag("hero-copy", useUnmergedTree = true).fetchSemanticsNodes().map { it.config[HeroLayerIdentity] }
|
||||||
|
private fun press(key: Key) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(key); keyUp(key) }
|
||||||
|
private fun frames(phase: String, count: Int) = repeat(count) { index -> rule.mainClock.advanceTimeBy(16); sample(phase, index) }
|
||||||
|
private fun finish() = File(directory, "series.json").writeText(samples.toString(2))
|
||||||
|
private fun assertSmoothPixels() = assertTrue("A 16ms hero frame cannot snap to a full intermediate selection; largest channel jump=$largestJump", largestJump <= 48)
|
||||||
|
|
||||||
|
private fun sample(phase: String, index: Int) {
|
||||||
|
rule.waitForIdle()
|
||||||
|
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
val pixel = try {
|
||||||
|
lowerPixel = bitmap.getPixel(1600, 800)
|
||||||
|
bitmap.getPixel(1600, 200)
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
val channels = channels(pixel)
|
||||||
|
previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) }
|
||||||
|
previousPixel = pixel
|
||||||
|
val copy = JSONArray()
|
||||||
|
state.heroes.forEach { title -> rule.onAllNodesWithText(title.title, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||||
|
copy.put(JSONObject().put("title", title.key).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
|
||||||
|
} }
|
||||||
|
val layers = layerSamples()
|
||||||
|
val dots = JSONArray()
|
||||||
|
state.heroes.indices.forEach { dot -> rule.onAllNodesWithTag("hero-dot-$dot", useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||||
|
previousDotWidths[dot]?.let { previous ->
|
||||||
|
val range = with(rule.density) { (WolfskinTokens.Discover.dotActive - WolfskinTokens.Discover.dotSize).toPx() }
|
||||||
|
if (!reduced.value) assertTrue("Pager retarget must not snap a full dot-width step", kotlin.math.abs(node.boundsInRoot.width - previous) <= range / 2)
|
||||||
|
if (reduced.value) {
|
||||||
|
val rest = with(rule.density) { WolfskinTokens.Discover.dotSize.toPx() }
|
||||||
|
assertTrue("Reduced motion snaps pager geometry instead of moving it", node.boundsInRoot.width == rest || node.boundsInRoot.width == rest + range)
|
||||||
|
}
|
||||||
|
if (previousSelection == state.slide) {
|
||||||
|
if (dot == state.slide) assertTrue("The selected pager dot cannot shrink backwards", node.boundsInRoot.width >= previous - .01f)
|
||||||
|
else assertTrue("An outgoing pager dot cannot grow backwards", node.boundsInRoot.width <= previous + .01f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousDotWidths[dot] = node.boundsInRoot.width
|
||||||
|
dots.put(JSONObject().put("index", dot).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top).put("width", node.boundsInRoot.width))
|
||||||
|
} }
|
||||||
|
previousSelection = state.slide
|
||||||
|
samples.put(JSONObject().put("phase", phase).put("frame", index).put("timeMs", rule.mainClock.currentTime)
|
||||||
|
.put("selection", state.slide).put("mounted", mounted.value).put("rgb", JSONArray(channels)).put("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun layerSamples(): JSONArray {
|
||||||
|
val result = JSONArray()
|
||||||
|
val current = mutableMapOf<String, Pair<Float, Float>>()
|
||||||
|
val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||||
|
val unique = nodes.groupBy { it.config[SemanticsProperties.TestTag] + ":" + it.id }
|
||||||
|
if (mounted.value) assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
|
||||||
|
val visibleCopy = nodes.count { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") && it.config[HeroLayerAlpha] > 0f }
|
||||||
|
assertTrue("Sequential copy cannot paint overlapping titles, including interruptions", visibleCopy <= 1)
|
||||||
|
val artCount = nodes.count { !it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||||
|
assertTrue("Sustained presses cannot extend outgoing art lifetimes", artCount <= (WolfskinTokens.Motion.heroFade + 15) / 16 + 3)
|
||||||
|
unique.forEach { (key, matching) -> matching.forEach { node ->
|
||||||
|
val alpha = node.config[HeroLayerAlpha]
|
||||||
|
val translation = node.config[HeroLayerTranslation]
|
||||||
|
val tag = node.config[SemanticsProperties.TestTag]
|
||||||
|
if (matching.size == 1) {
|
||||||
|
previousLayers[key]?.let { (previousAlpha, previousTranslation) ->
|
||||||
|
if (tag == "hero-outgoing" || tag == "hero-copy-outgoing") assertTrue("Outgoing layers cannot restart at full opacity: $key", alpha <= previousAlpha + .001f)
|
||||||
|
if (tag == "hero-copy-outgoing" && alpha > 0f) assertEquals("Visible outgoing copy must fade where its entry stopped", previousTranslation, translation, .001f)
|
||||||
|
if (tag == "hero-copy") {
|
||||||
|
assertTrue("Copy alpha cannot snap backwards: $key", alpha >= previousAlpha - .001f)
|
||||||
|
if (alpha > 0f && previousAlpha > 0f) assertTrue("Visible copy translation cannot snap backwards", translation <= previousTranslation + .001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current[key] = alpha to translation
|
||||||
|
}
|
||||||
|
if (reduced.value) assertEquals(0f, translation, .001f)
|
||||||
|
result.put(JSONObject().put("kind", tag).put("identity", node.config[HeroLayerIdentity]).put("layerId", node.id).put("alpha", alpha)
|
||||||
|
.put("translationY", translation).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
|
||||||
|
} }
|
||||||
|
previousLayers = current
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channels(pixel: Int) = listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import android.view.KeyEvent as AndroidKeyEvent
|
import android.view.KeyEvent as AndroidKeyEvent
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
|
@ -19,10 +22,12 @@ import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/** Acceptance data drives real Android events through the shell's production focus adapter. */
|
/** Acceptance data drives real Android events through the shell's production focus adapter. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
class DiscoverKeysTest {
|
class DiscoverKeysTest {
|
||||||
@get:Rule val rule = createComposeRule()
|
@get:Rule val rule = createComposeRule()
|
||||||
private val fixture = discoverTestFixture()
|
private val fixture = discoverTestFixture()
|
||||||
|
|
@ -33,11 +38,14 @@ class DiscoverKeysTest {
|
||||||
{ shell.enterDestination("discover") }, fixture::service)
|
{ shell.enterDestination("discover") }, fixture::service)
|
||||||
|
|
||||||
private fun launch() {
|
private fun launch() {
|
||||||
|
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
|
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
|
||||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
||||||
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,7 +264,69 @@ class DiscoverKeysTest {
|
||||||
assertNull(state.collection)
|
assertNull(state.collection)
|
||||||
assertSame(origin, shell.view)
|
assertSame(origin, shell.view)
|
||||||
assertEquals(3, shell.view.row)
|
assertEquals(3, shell.view.row)
|
||||||
assertEquals(2, shell.view.cards[state.rowKey])
|
assertEquals(2, state.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun firstEntryStartsLeftmostAndOnlyMovedLanesRememberTheirPosition() {
|
||||||
|
replay("Open Discover")
|
||||||
|
press(Key.DirectionDown, Key.DirectionDown)
|
||||||
|
assertEquals(0, state.column)
|
||||||
|
repeat(7) { press(Key.DirectionRight) }
|
||||||
|
val moved = state.column
|
||||||
|
assertTrue(moved > 0)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("An untouched lane starts at its own left edge, not the previous lane's column", 0, state.column)
|
||||||
|
press(Key.DirectionUp)
|
||||||
|
assertEquals(moved, state.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun leavingTheTabResetsLaneMovementButPushedTitlesDoNot() {
|
||||||
|
replay("Open Discover")
|
||||||
|
press(Key.DirectionDown, Key.DirectionDown)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
repeat(4) { press(Key.DirectionRight) }
|
||||||
|
val row = state.focusedRow
|
||||||
|
val moved = state.column
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertNotNull("This round trip must open a title, not a service lane", state.title)
|
||||||
|
press(Key.Back)
|
||||||
|
assertEquals(moved, state.column)
|
||||||
|
press(Key.Back)
|
||||||
|
press(Key.DirectionLeft, Key.DirectionCenter)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
press(Key.DirectionUp, Key.DirectionRight)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals("discover", shell.route)
|
||||||
|
assertEquals(row, state.focusedRow)
|
||||||
|
assertEquals(0, state.column)
|
||||||
|
assertEquals(0, state.laneFirsts[state.rowKey] ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rebuildingDiscoverDoesNotReuseThePreviousInstancesLaneColumns() {
|
||||||
|
shell.pickerVisible = false
|
||||||
|
shell.enterDestination("discover")
|
||||||
|
state.focus(state.firstShelf, 4)
|
||||||
|
val rebuilt = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate))
|
||||||
|
assertEquals(0, rebuilt.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun serviceLanesKeepIndependentPositionsAndResetWithTheRootVisit() {
|
||||||
|
shell.pickerVisible = false
|
||||||
|
shell.enterDestination("discover")
|
||||||
|
state.focus(state.firstShelf + 1, 3)
|
||||||
|
state.openService(snapshot.services[0])
|
||||||
|
state.focus(state.firstShelf + 1, 4)
|
||||||
|
state.key(ShellKey.Back)
|
||||||
|
assertEquals("A service must not overwrite the root lane", 3, state.column)
|
||||||
|
state.openService(snapshot.services[1])
|
||||||
|
state.key(ShellKey.Down)
|
||||||
|
assertEquals("Different services can reuse lane ids without sharing columns", 0, state.column)
|
||||||
|
state.key(ShellKey.Back)
|
||||||
|
state.openService(snapshot.services[0])
|
||||||
|
state.key(ShellKey.Down)
|
||||||
|
assertEquals(4, state.column)
|
||||||
|
state.enterRoute("home")
|
||||||
|
assertEquals("Leaving Discover clears service positions too", 0, state.column)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
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.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.decode.DataSource
|
||||||
|
import coil3.intercept.Interceptor
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Network-success artwork must not add Coil's independent alpha ramp beneath the hero blend. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class DiscoverNetworkHeroTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun delayedNetworkSuccessCannotDipThePaintedHeroOpacity() {
|
||||||
|
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
|
||||||
|
val red = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.RED) }.asImageBitmap()
|
||||||
|
val blue = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.BLUE) }.asImage()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
|
||||||
|
if (chain.request.data == "network:blue") {
|
||||||
|
release.await()
|
||||||
|
SuccessResult(blue, chain.request, DataSource.NETWORK)
|
||||||
|
} else chain.proceed()
|
||||||
|
}) }
|
||||||
|
val fixture = discoverTestFixture()
|
||||||
|
val titles = fixture.snapshot("justin").picks.take(2).mapIndexed { index, title ->
|
||||||
|
title.copy(backdrop = if (index == 0) "network:red" else "network:blue", poster = null, posterOnly = false, score = 100.0 - index)
|
||||||
|
}
|
||||||
|
val snapshot = fixture.snapshot("justin").copy(picks = titles)
|
||||||
|
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
|
||||||
|
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate))
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
CompositionLocalProvider(LocalDiscoverArt provides { path -> if (path == "network:red") red else null }) {
|
||||||
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionRight); keyUp(Key.DirectionRight) }
|
||||||
|
rule.mainClock.advanceTimeBy(192)
|
||||||
|
rule.onNodeWithText(titles.first().title).assertIsDisplayed()
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-network-hero").apply { mkdirs() }
|
||||||
|
val samples = JSONArray()
|
||||||
|
rule.runOnIdle { release.complete(Unit) }
|
||||||
|
var lastPixel = 0
|
||||||
|
repeat(40) { index ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
val file = File(directory, "network-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
lastPixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
|
||||||
|
val redChannel = android.graphics.Color.red(lastPixel)
|
||||||
|
val blueChannel = android.graphics.Color.blue(lastPixel)
|
||||||
|
samples.put(JSONObject().put("timeMs", rule.mainClock.currentTime).put("red", redChannel).put("blue", blueChannel))
|
||||||
|
assertTrue("A second network-image fade exposes the dark placeholder beneath both heroes at frame $index: red=$redChannel blue=$blueChannel", redChannel + blueChannel >= 250)
|
||||||
|
}
|
||||||
|
File(directory, "series.json").writeText(samples.toString(2))
|
||||||
|
assertEquals(android.graphics.Color.BLUE, lastPixel)
|
||||||
|
rule.onNodeWithText(titles.last().title).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
|
@ -59,16 +62,22 @@ class HeroMotionTest {
|
||||||
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||||
try {
|
try {
|
||||||
for (y in listOf(380, 400, 420, 600, 800)) {
|
for (y in listOf(380, 400, 420, 600, 800)) {
|
||||||
assertEquals("The outgoing hero must stop at its 400px boundary: y=$y",
|
assertEquals("Pending artwork cannot replace any part of the painted backdrop: y=$y",
|
||||||
(if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y))
|
Color.Red.toArgb(), probe.getPixel(100, y))
|
||||||
}
|
}
|
||||||
assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
|
||||||
} finally { probe.recycle() }
|
} finally { probe.recycle() }
|
||||||
update { ready.value = true }
|
update { ready.value = true }
|
||||||
rule.mainClock.advanceTimeBy(176)
|
rule.mainClock.advanceTimeBy(176)
|
||||||
val mixed = pixel(directory, 100)
|
val mixed = pixel(directory, 100)
|
||||||
assertNotEquals(Color.Red.toArgb(), mixed)
|
assertNotEquals(Color.Red.toArgb(), mixed)
|
||||||
assertNotEquals(Color.Blue.toArgb(), mixed)
|
assertNotEquals(Color.Blue.toArgb(), mixed)
|
||||||
|
val blendedProbe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||||
|
try {
|
||||||
|
assertEquals("Only the outgoing blend is clipped at its 400px boundary",
|
||||||
|
400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
||||||
|
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 400))
|
||||||
|
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 800))
|
||||||
|
} finally { blendedProbe.recycle() }
|
||||||
rule.mainClock.advanceTimeBy(400)
|
rule.mainClock.advanceTimeBy(400)
|
||||||
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
||||||
update { selected.value = "green"; ready.value = false }
|
update { selected.value = "green"; ready.value = false }
|
||||||
|
|
@ -82,6 +91,35 @@ class HeroMotionTest {
|
||||||
} finally { directory.deleteRecursively() }
|
} finally { directory.deleteRecursively() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun homeHeroRetainsTheOutgoingCopyAnchorWhenTheNextTitleWraps() {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
|
||||||
|
val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
|
||||||
|
val selected = mutableStateOf(first)
|
||||||
|
val art = mapOf("red" to Color.Red, "blue" to Color.Blue).mapValues { (_, color) ->
|
||||||
|
android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(color.toArgb()) }.asImageBitmap()
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp)) {
|
||||||
|
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot
|
||||||
|
val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
|
||||||
|
update { selected.value = next }
|
||||||
|
repeat(WolfskinTokens.Motion.heroCopyOut / 16) {
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertEquals("A taller incoming copy cannot move the outgoing title", anchor, rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot)
|
||||||
|
assertEquals("Hero actions remain stationary outside the copy handoff", actionAnchor, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.onNodeWithText(first.title).assertDoesNotExist()
|
||||||
|
rule.onNodeWithText(next.title).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
private fun update(block: () -> Unit) {
|
private fun update(block: () -> Unit) {
|
||||||
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
rule.mainClock.advanceTimeByFrame()
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Real Home hero entry, interrupted fade-through and saved remount sampled at native 16 ms steps. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class HomeHeroSequenceTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
|
||||||
|
private val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
|
||||||
|
private val third = HomeTitle("third", "The final selected title", "movie", backdrop = "green")
|
||||||
|
private val selected = mutableStateOf(first)
|
||||||
|
private val reduced = mutableStateOf(false)
|
||||||
|
private val mounted = mutableStateOf(true)
|
||||||
|
private val samples = JSONArray()
|
||||||
|
private lateinit var directory: File
|
||||||
|
private lateinit var actionBounds: Rect
|
||||||
|
|
||||||
|
@Test fun initialSequentialBurstAndRemountKeepHomeCopyReadable() {
|
||||||
|
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
|
||||||
|
directory = File(System.getProperty("wolfskin.captureDirectory"), "home-hero-copy").apply { mkdirs() }
|
||||||
|
val art = mapOf("red" to android.graphics.Color.RED, "blue" to android.graphics.Color.BLUE, "green" to android.graphics.Color.GREEN).mapValues { (_, color) ->
|
||||||
|
Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap()
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
val saved = rememberSaveableStateHolder()
|
||||||
|
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides art) {
|
||||||
|
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
|
||||||
|
if (mounted.value) saved.SaveableStateProvider("home-hero") {
|
||||||
|
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
actionBounds = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
|
||||||
|
sample("initial", 0)
|
||||||
|
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
|
||||||
|
frames("entry", 16)
|
||||||
|
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
rule.runOnIdle { selected.value = next }
|
||||||
|
frames("handoff", 26)
|
||||||
|
assertEquals(next.id, copy().config[HeroLayerIdentity])
|
||||||
|
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
repeat(24) { index ->
|
||||||
|
rule.runOnIdle { selected.value = if (index % 2 == 0) first else third }
|
||||||
|
frames("burst-$index", 1)
|
||||||
|
}
|
||||||
|
frames("settled", 28)
|
||||||
|
assertEquals(third.id, copy().config[HeroLayerIdentity])
|
||||||
|
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
rule.runOnIdle { reduced.value = true; selected.value = next }
|
||||||
|
frames("reduced", 20)
|
||||||
|
assertEquals(next.id, copy().config[HeroLayerIdentity])
|
||||||
|
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
rule.runOnIdle { mounted.value = false }
|
||||||
|
frames("absent", 1)
|
||||||
|
rule.runOnIdle { mounted.value = true }
|
||||||
|
repeat(8) { frame ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
sample("remount", frame)
|
||||||
|
assertEquals("Returning Home cannot replay the initial entrance", 1f, copy().config[HeroLayerAlpha], .001f)
|
||||||
|
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
|
||||||
|
}
|
||||||
|
File(directory, "series.json").writeText(samples.toString(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun frames(phase: String, count: Int) = repeat(count) { frame -> rule.mainClock.advanceTimeBy(16); sample(phase, frame) }
|
||||||
|
private fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||||
|
.single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||||
|
|
||||||
|
private fun sample(phase: String, frame: Int) {
|
||||||
|
rule.waitForIdle()
|
||||||
|
val file = File(directory, "$phase-${frame.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
val pixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
|
||||||
|
val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||||
|
val copyNodes = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||||
|
assertTrue("Home must never overlap visible titles, even during bursts", copyNodes.count { it.config[HeroLayerAlpha] > 0f } <= 1)
|
||||||
|
if (mounted.value) {
|
||||||
|
assertTrue("Home artwork retains an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
|
||||||
|
assertEquals("Copy motion cannot move Home's controls", actionBounds, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
|
||||||
|
}
|
||||||
|
val layers = JSONArray()
|
||||||
|
nodes.forEach { node ->
|
||||||
|
val translation = node.config[HeroLayerTranslation]
|
||||||
|
if (reduced.value) assertEquals(0f, translation, .001f)
|
||||||
|
layers.put(JSONObject().put("kind", node.config[SemanticsProperties.TestTag]).put("identity", node.config[HeroLayerIdentity])
|
||||||
|
.put("layerId", node.id).put("alpha", node.config[HeroLayerAlpha]).put("translationY", translation)
|
||||||
|
.put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
|
||||||
|
}
|
||||||
|
samples.put(JSONObject().put("phase", phase).put("frame", frame).put("timeMs", rule.mainClock.currentTime)
|
||||||
|
.put("mounted", mounted.value).put("selection", selected.value.id).put("reduced", reduced.value)
|
||||||
|
.put("rgb", JSONArray(listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))))
|
||||||
|
.put("actionX", actionBounds.left).put("actionY", actionBounds.top).put("layers", layers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
|
|
@ -44,20 +45,19 @@ class MotionSurfaceTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||||
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
|
||||||
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
|
@Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() {
|
||||||
val shell = shell()
|
val shell = shell()
|
||||||
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
||||||
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
||||||
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } }
|
||||||
rule.waitForIdle()
|
|
||||||
val before = bounds()
|
val before = bounds()
|
||||||
for (stage in listOf("Ready", "Downloading", null)) {
|
assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) }
|
||||||
rule.mainClock.advanceTimeBy(3400)
|
assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||||
rule.waitForIdle()
|
bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) }
|
||||||
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) }
|
||||||
}
|
assertEquals(before, bounds())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
||||||
|
|
@ -67,7 +67,13 @@ class MotionSurfaceTest {
|
||||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
||||||
rule.mainClock.advanceTimeBy(2100)
|
rule.mainClock.advanceTimeBy(2100)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) }
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
|
assertEquals("header", state.row)
|
||||||
|
assertEquals(0, state.column)
|
||||||
|
}
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||||
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
|
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */
|
||||||
|
class OwnedSourceAuditTest {
|
||||||
|
@Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() {
|
||||||
|
val roots = listOf(File("src/main/java"), File("../app/src/wolfskin"))
|
||||||
|
roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) }
|
||||||
|
val violations = roots.flatMap { root ->
|
||||||
|
root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source ->
|
||||||
|
audit(source.name, source.readText()).map { "$source:$it" }
|
||||||
|
}.toList()
|
||||||
|
}
|
||||||
|
assertTrue(violations.joinToString("\n"), violations.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun plantedViolationsFailEachDoneBarRule() {
|
||||||
|
val cases = listOf(
|
||||||
|
Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"),
|
||||||
|
Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"),
|
||||||
|
Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"),
|
||||||
|
Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"),
|
||||||
|
Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"),
|
||||||
|
Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"),
|
||||||
|
Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"),
|
||||||
|
Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"),
|
||||||
|
Triple("Screen.kt", "Text(\"DGiC\")", "wording"),
|
||||||
|
Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"),
|
||||||
|
Triple("Screen.kt", "Text(\"Next page\")", "wording"),
|
||||||
|
Triple("Screen.kt", "Text(\"Previous page\")", "wording"),
|
||||||
|
Triple("Screen.kt", "Text(\"Page 2\")", "wording"),
|
||||||
|
)
|
||||||
|
cases.forEach { (name, source, rule) ->
|
||||||
|
assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") })
|
||||||
|
}
|
||||||
|
assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty())
|
||||||
|
assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audit(name: String, source: String): List<String> {
|
||||||
|
val generated = name == "WolfskinTokens.kt"
|
||||||
|
return source.lineSequence().mapIndexedNotNull { index, line ->
|
||||||
|
val code = line.trimStart()
|
||||||
|
if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null
|
||||||
|
val rules = buildList {
|
||||||
|
if (!generated && units.containsMatchIn(line)) add("unit")
|
||||||
|
if (!generated && colors.containsMatchIn(line)) add("color")
|
||||||
|
if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor")
|
||||||
|
if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording")
|
||||||
|
}
|
||||||
|
rules.map { "${index + 1}:$it" }
|
||||||
|
}.flatten().toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val units = Regex("\\.(?:dp|sp)\\b")
|
||||||
|
private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)")
|
||||||
|
private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b")
|
||||||
|
private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"")
|
||||||
|
private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,7 +68,7 @@ class ShellCaptureTest {
|
||||||
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
|
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
|
||||||
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
||||||
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||||
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 }
|
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
|
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
|
||||||
|
|
|
||||||
|
|
@ -46,22 +46,6 @@ class ShellKeysTest {
|
||||||
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
|
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() {
|
|
||||||
val actions = mutableListOf<ShellAction>()
|
|
||||||
val shell = ShellState(fixturePeople(), "justin", actions::add).apply {
|
|
||||||
pickerVisible = false
|
|
||||||
enterDestination("settings")
|
|
||||||
}
|
|
||||||
rule.setContent { WolfskinTheme { ShellSurface(shell) } }
|
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
assertEquals(1, shell.settingsRow)
|
|
||||||
assertNull(shell.handOff)
|
|
||||||
assertEquals(listOf(ShellAction.Destination("updates")), actions)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun settingsBackBackInTenPresses() {
|
@Test fun settingsBackBackInTenPresses() {
|
||||||
replay(settings + listOf(Key.Back, Key.Back), 10)
|
replay(settings + listOf(Key.Back, Key.Back), 10)
|
||||||
assertEquals("home", state.route)
|
assertEquals("home", state.route)
|
||||||
|
|
@ -70,10 +54,10 @@ class ShellKeysTest {
|
||||||
assertEquals(state.tabs.size, state.navIndex)
|
assertEquals(state.tabs.size, state.navIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun playbackHandOffInTwelvePresses() {
|
@Test fun playbackHandOffInElevenPresses() {
|
||||||
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12)
|
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
|
||||||
assertEquals("playback", state.handOff)
|
assertEquals("playback", state.handOff)
|
||||||
assertEquals(3, state.settingsRow)
|
assertEquals(2, state.settingsRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue