Compare commits
22 commits
fm/tv-skip
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fc10b7af2 | |||
| 9061384ad1 | |||
| 9bbb4bab25 | |||
| 57cf17aaad | |||
| 51799669e7 | |||
| 383802611c | |||
| 94a69de25f | |||
| c78a8dfa8d | |||
| d171aaae9e | |||
| 83ebf25d9f | |||
| 37ad87d058 | |||
| 2b8c729f62 | |||
| 271cee2cea | |||
| ee3f3ae11e | |||
| 19df50c0f1 | |||
| 7a0cbdf909 | |||
| e189d9318a | |||
| 152dac97c0 | |||
| 00e8e83bb6 | |||
| b80b67f736 | |||
|
|
159edc875d | ||
|
|
bd46ff3e03 |
100 changed files with 4364 additions and 1801 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
|
.PHONY: build-image apk apk-debug test release-tag tv-release tv-publish tv-tooling-test
|
||||||
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,3 +19,13 @@ 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,6 +8,14 @@ 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,6 +56,10 @@ 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
|
||||||
|
|
@ -228,6 +232,13 @@ 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 ->
|
||||||
|
|
|
||||||
18
app/household-version.gradle.kts
Normal file
18
app/household-version.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// Household assembly inputs remain mandatory even through aggregate Gradle tasks.
|
||||||
|
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||||
|
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||||
|
|
||||||
|
fun requireHouseholdVersion() {
|
||||||
|
require(tvVersionCode.isPresent) { "Household builds require -PtvVersionCode=<monotonic code>" }
|
||||||
|
require(tvVersionCode.get().toInt() in 1..2100000000) { "Invalid household versionCode" }
|
||||||
|
require(Regex("""\d+\.\d+\.\d+""").matches(tvVersion.get())) { "Household versionName must be plain major.minor.patch" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gradle.startParameter.taskNames.any { it.contains("Optimized", ignoreCase = true) }) {
|
||||||
|
requireHouseholdVersion()
|
||||||
|
}
|
||||||
|
tasks.configureEach {
|
||||||
|
if (name.contains("DefaultOptimized", ignoreCase = true)) {
|
||||||
|
doFirst { requireHouseholdVersion() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,13 @@
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<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
|
||||||
|
|
@ -70,6 +77,13 @@
|
||||||
<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"
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.RestoredSession
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -480,13 +479,11 @@ class MainActivityViewModel
|
||||||
fun appResume() {
|
fun appResume() {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val expected = serverRepository.current.value
|
|
||||||
try {
|
try {
|
||||||
Timber.v("Updating userDto")
|
Timber.v("Updating userDto")
|
||||||
serverRepository.updateUserDto()
|
serverRepository.updateUserDto()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Error during appResume")
|
Timber.w(ex, "Error during appResume")
|
||||||
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -593,7 +590,6 @@ class MainActivityViewModel
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
Timber.w(exception, "Saved launch session revalidation failed")
|
Timber.w(exception, "Saved launch session revalidation failed")
|
||||||
recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinApp
|
import org.justinvisser.wolfskin.tv.WolfskinApp
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinSetupContent
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||||
|
|
@ -49,7 +48,6 @@ fun MainContent(
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
|
||||||
) {
|
) {
|
||||||
val preferences by rememberUpdatedState(userPreferences)
|
val preferences by rememberUpdatedState(userPreferences)
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -74,7 +72,6 @@ fun MainContent(
|
||||||
entryProvider = { key ->
|
entryProvider = { key ->
|
||||||
key as SetupDestination
|
key as SetupDestination
|
||||||
NavEntry(key) {
|
NavEntry(key) {
|
||||||
if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry
|
|
||||||
when (key) {
|
when (key) {
|
||||||
SetupDestination.Loading -> {
|
SetupDestination.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.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
|
||||||
|
|
@ -627,7 +628,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 = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
|
defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID),
|
||||||
getter = { it.updateUrl },
|
getter = { it.updateUrl },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.update { updateUrl = value }
|
prefs.update { updateUrl = value }
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ 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
|
||||||
|
|
@ -43,6 +46,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -55,6 +59,7 @@ 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"
|
||||||
|
|
@ -123,6 +128,10 @@ 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 =
|
||||||
|
|
@ -181,6 +190,12 @@ 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()
|
||||||
|
|
@ -233,6 +248,9 @@ 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,6 +47,7 @@ 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
|
||||||
|
|
@ -89,6 +90,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -355,7 +357,9 @@ fun PreferencesContent(
|
||||||
AppPreference.Update -> {
|
AppPreference.Update -> {
|
||||||
ClickPreference(
|
ClickPreference(
|
||||||
title =
|
title =
|
||||||
if (release != null && updateAvailable) {
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
stringResource(R.string.check_for_updates)
|
||||||
|
} else if (release != null && updateAvailable) {
|
||||||
stringResource(R.string.install_update)
|
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)
|
||||||
|
|
@ -364,7 +368,9 @@ fun PreferencesContent(
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (movementSounds) playOnClickSound(context)
|
if (movementSounds) playOnClickSound(context)
|
||||||
if (release != null && updateAvailable) {
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||||
|
} else if (release != null && updateAvailable) {
|
||||||
release?.let {
|
release?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.UpdateApp,
|
Destination.UpdateApp,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ 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
|
||||||
|
|
@ -73,6 +74,9 @@ 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
|
||||||
|
|
@ -82,6 +86,7 @@ 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())
|
||||||
|
|
@ -93,6 +98,10 @@ 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
|
||||||
|
|
@ -166,6 +175,10 @@ 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,6 +52,9 @@ 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,6 +18,16 @@ 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,7 +22,14 @@ 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.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
|
Assert.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun householdAvailabilityOrdersAndroidCodesNotDisplayVersions() {
|
||||||
|
Assert.assertTrue(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 65))
|
||||||
|
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(65, 100001))
|
||||||
|
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 100001))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,16 @@ import javax.inject.Singleton
|
||||||
@Module
|
@Module
|
||||||
@TestInstallIn(
|
@TestInstallIn(
|
||||||
components = [SingletonComponent::class],
|
components = [SingletonComponent::class],
|
||||||
replaces = [DeviceModule::class, AppModule::class],
|
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::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))
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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))
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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))
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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))
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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)) {}.menuOptions.any { it.id == "add" })
|
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,6 @@ 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) {
|
||||||
|
|
@ -100,8 +97,7 @@ 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 == "request" })
|
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
|
||||||
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
|
|
||||||
} finally { viewModel.viewModelScope.cancel() }
|
} finally { viewModel.viewModelScope.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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)).also { models.add(it) }
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
|
||||||
}
|
}
|
||||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
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))
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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))
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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))
|
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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))
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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))
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
|
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||||
|
|
@ -170,10 +170,7 @@ 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 ->
|
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
|
||||||
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") }
|
||||||
|
|
@ -219,10 +216,4 @@ 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.")) })
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
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
|
||||||
|
|
@ -33,6 +36,7 @@ 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)
|
||||||
|
|
@ -280,6 +284,15 @@ 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) })
|
||||||
|
|
@ -302,6 +315,26 @@ 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() {
|
||||||
|
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
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,166 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.View
|
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.snapshots.Snapshot
|
|
||||||
import org.robolectric.Shadows
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.test.*
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.github.damontecres.wholphin.MainActivityViewModel
|
|
||||||
import com.github.damontecres.wholphin.MainContent
|
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.data.CurrentUser
|
|
||||||
import com.github.damontecres.wholphin.data.RestoredSession
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.*
|
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
|
||||||
import io.mockk.coVerify
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.After
|
|
||||||
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.conductarr.MemoryCacheStore
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
import org.robolectric.annotation.GraphicsMode
|
|
||||||
|
|
||||||
/** Exercise the actual MainContent boundary, not a parallel copy of its destination mapping. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
|
||||||
class WolfskinSetupNavigationTest {
|
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
|
||||||
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
|
|
||||||
private val repository = mockk<ServerRepository>(relaxed = true)
|
|
||||||
private val navigation = NavigationManager()
|
|
||||||
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
|
||||||
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.Loading)
|
|
||||||
private val conductarr = ConductarrSession(MemoryCacheStore())
|
|
||||||
private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr)
|
|
||||||
private val preferences = mockk<DataStore<AppPreferences>>()
|
|
||||||
private val intentService = mockk<IntentService>()
|
|
||||||
private var previousScreens = true
|
|
||||||
private var previousFixtures = false
|
|
||||||
|
|
||||||
@Before fun configure() {
|
|
||||||
WolfskinScreenPreference.initialize(rule.activity)
|
|
||||||
previousScreens = WolfskinScreenPreference.enabled
|
|
||||||
WolfskinScreenPreference.enabled = true
|
|
||||||
ConductarrPreference.initialize(rule.activity)
|
|
||||||
previousFixtures = ConductarrPreference.fixtures
|
|
||||||
ConductarrPreference.updateFixtures(false)
|
|
||||||
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
|
|
||||||
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server)
|
|
||||||
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
|
||||||
every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user)))
|
|
||||||
every { setup.navigateTo(any()) } answers { stack[0] = firstArg() }
|
|
||||||
every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance())
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@After fun close() {
|
|
||||||
conductarr.close()
|
|
||||||
WolfskinScreenPreference.enabled = previousScreens
|
|
||||||
ConductarrPreference.updateFixtures(previousFixtures)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mount() {
|
|
||||||
val screensaver = mockk<ScreensaverService>(relaxed = true)
|
|
||||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
|
||||||
rule.setContent {
|
|
||||||
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
|
||||||
mockk(relaxed = true), screensaver, Modifier,
|
|
||||||
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
|
|
||||||
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService)
|
|
||||||
|
|
||||||
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
|
|
||||||
rule.waitUntil(5_000) {
|
|
||||||
Snapshot.sendApplyNotifications()
|
|
||||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
|
||||||
model.stage == stage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assertOwnPicker() {
|
|
||||||
awaitStage(WolfskinSetupViewModel.Stage.Picker)
|
|
||||||
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
|
||||||
rule.onNodeWithText("Justin").assertIsDisplayed()
|
|
||||||
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
|
|
||||||
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
|
|
||||||
runBlocking { appModel().handleAppStart() }
|
|
||||||
mount()
|
|
||||||
assertOwnPicker()
|
|
||||||
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
|
|
||||||
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
|
|
||||||
rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) }
|
|
||||||
assertOwnPicker()
|
|
||||||
rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) }
|
|
||||||
assertOwnPicker()
|
|
||||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in")
|
|
||||||
rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } }
|
|
||||||
rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList }
|
|
||||||
assertOwnPicker()
|
|
||||||
coVerify(exactly = 0) { repository.switchServerOrUser() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun unreachableSelectionRendersWolfskinRecoveryFrame() {
|
|
||||||
stack[0] = SetupDestination.UserList(server)
|
|
||||||
coEvery { repository.changeUser(server, user) } throws IOException("offline")
|
|
||||||
mount()
|
|
||||||
assertOwnPicker()
|
|
||||||
rule.onNodeWithText("Justin").performClick()
|
|
||||||
awaitStage(WolfskinSetupViewModel.Stage.Unavailable)
|
|
||||||
rule.onNodeWithText("Try again").assertIsDisplayed()
|
|
||||||
rule.onNodeWithText("Sign in").assertIsDisplayed()
|
|
||||||
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
|
|
||||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() }
|
|
||||||
rule.onRoot().captureRoboImage(File(directory, "household-unreachable.png"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun unconfiguredFirstRunOffersUrlInsteadOfStockServerSelector() {
|
|
||||||
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
|
|
||||||
every { repository.serverDao.getServers() } returns emptyList()
|
|
||||||
stack[0] = SetupDestination.ServerList
|
|
||||||
mount()
|
|
||||||
awaitStage(WolfskinSetupViewModel.Stage.Unconfigured)
|
|
||||||
rule.onNodeWithText("Connect your household server").assertIsDisplayed()
|
|
||||||
rule.onNodeWithTag("household-server-url").assertIsDisplayed().performTextInput("https://jellyfin.test")
|
|
||||||
rule.onNodeWithText("Try again").assertIsEnabled()
|
|
||||||
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.pm.ActivityInfo
|
|
||||||
import android.os.Looper
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.github.damontecres.wholphin.MainContent
|
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverState
|
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
|
||||||
import com.github.damontecres.wholphin.test.TestActivity
|
|
||||||
import com.github.damontecres.wholphin.ui.TestModule
|
|
||||||
import com.github.damontecres.wholphin.ui.successResponse
|
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
|
||||||
import com.github.damontecres.wholphin.util.configure
|
|
||||||
import com.github.damontecres.wholphin.util.reset
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
|
||||||
import dagger.hilt.android.testing.HiltTestApplication
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import org.jellyfin.sdk.Jellyfin
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
|
||||||
import org.jellyfin.sdk.api.operations.QuickConnectApi
|
|
||||||
import org.jellyfin.sdk.api.operations.SystemApi
|
|
||||||
import org.jellyfin.sdk.api.operations.UserApi
|
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.rules.TestWatcher
|
|
||||||
import org.junit.runner.Description
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.Shadows.shadowOf
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltAndroidTest
|
|
||||||
@Config(application = HiltTestApplication::class, sdk = [34])
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
class WolfskinSetupRollbackTest {
|
|
||||||
@get:Rule(order = 0) val hilt = HiltAndroidRule(this)
|
|
||||||
@get:Rule(order = 1) val registerActivity = object : TestWatcher() {
|
|
||||||
override fun starting(description: Description?) {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
|
||||||
shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply {
|
|
||||||
name = TestActivity::class.java.name
|
|
||||||
packageName = context.packageName
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@get:Rule(order = 2) val rule = createAndroidComposeRule<TestActivity>()
|
|
||||||
@Inject lateinit var api: ApiClient
|
|
||||||
@Inject lateinit var jellyfin: Jellyfin
|
|
||||||
private var previousScreens = true
|
|
||||||
|
|
||||||
@Before fun setup() {
|
|
||||||
hilt.inject()
|
|
||||||
WholphinDispatchers.configure(TestModule.testDispatcher)
|
|
||||||
WolfskinScreenPreference.initialize(rule.activity)
|
|
||||||
previousScreens = WolfskinScreenPreference.enabled
|
|
||||||
WolfskinScreenPreference.enabled = false
|
|
||||||
val users = mockk<UserApi>()
|
|
||||||
val system = mockk<SystemApi>()
|
|
||||||
val quickConnect = mockk<QuickConnectApi>()
|
|
||||||
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
|
|
||||||
every { api.userApi } returns users
|
|
||||||
every { api.systemApi } returns system
|
|
||||||
every { api.quickConnectApi } returns quickConnect
|
|
||||||
coEvery { users.getPublicUsers() } returns successResponse(emptyList())
|
|
||||||
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11"))
|
|
||||||
coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After fun restore() {
|
|
||||||
WolfskinScreenPreference.enabled = previousScreens
|
|
||||||
WholphinDispatchers.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun rollbackRendersBothStockSelectors() {
|
|
||||||
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
|
|
||||||
val screensaver = mockk<ScreensaverService>()
|
|
||||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
|
||||||
rule.setContent {
|
|
||||||
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
|
||||||
mockk(relaxed = true), screensaver)
|
|
||||||
}
|
|
||||||
fun awaitHeading(resource: Int) {
|
|
||||||
rule.waitUntil(5_000) {
|
|
||||||
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
|
||||||
rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed()
|
|
||||||
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
|
|
||||||
}
|
|
||||||
awaitHeading(R.string.select_server)
|
|
||||||
rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) }
|
|
||||||
awaitHeading(R.string.select_user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.room.Room
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.github.damontecres.wholphin.data.AppDatabase
|
|
||||||
import com.github.damontecres.wholphin.data.CurrentUser
|
|
||||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
|
||||||
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
|
||||||
import com.github.damontecres.wholphin.ui.successResponse
|
|
||||||
import com.github.damontecres.wholphin.ui.TestModule
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
|
||||||
import org.jellyfin.sdk.api.operations.SystemApi
|
|
||||||
import org.jellyfin.sdk.api.operations.UserApi
|
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
|
||||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class)
|
|
||||||
class WolfskinSetupTest {
|
|
||||||
private val context get() = ApplicationProvider.getApplicationContext<Application>()
|
|
||||||
private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() }
|
|
||||||
private val recent by lazy { MostRecentServerSharedPreferences(context) }
|
|
||||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
|
||||||
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token")
|
|
||||||
private val api = mockk<ApiClient>(relaxed = true)
|
|
||||||
private val usersApi = mockk<UserApi>()
|
|
||||||
private val systemApi = mockk<SystemApi>()
|
|
||||||
private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) }
|
|
||||||
private val navigation = NavigationManager()
|
|
||||||
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
|
||||||
private val session = WolfskinSession()
|
|
||||||
private val conductarr = ConductarrSession(MemoryCacheStore())
|
|
||||||
private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) }
|
|
||||||
private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) }
|
|
||||||
private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false)
|
|
||||||
private var previousFixtures = false
|
|
||||||
|
|
||||||
@Before fun saveHousehold(): Unit = runBlocking {
|
|
||||||
ConductarrPreference.initialize(context)
|
|
||||||
previousFixtures = ConductarrPreference.fixtures
|
|
||||||
ConductarrPreference.updateFixtures(false)
|
|
||||||
recent.clear()
|
|
||||||
database.serverDao().addOrUpdateServer(server)
|
|
||||||
user = database.serverDao().addOrUpdateUser(user)
|
|
||||||
recent.save(CurrentUser(server, user))
|
|
||||||
val client = jellyfin
|
|
||||||
every { api.userApi } returns usersApi
|
|
||||||
every { api.systemApi } returns systemApi
|
|
||||||
every { client.createApi(any(), any(), any(), any(), any()) } returns api
|
|
||||||
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
|
||||||
coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
@After fun close() {
|
|
||||||
conductarr.close()
|
|
||||||
database.close()
|
|
||||||
ConductarrPreference.updateFixtures(previousFixtures)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking {
|
|
||||||
val saved = recent.get()
|
|
||||||
model.load(null)
|
|
||||||
assertEquals(Stage.Picker, model.stage)
|
|
||||||
assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId)
|
|
||||||
assertEquals(saved, recent.get())
|
|
||||||
recent.clear()
|
|
||||||
model.load(null)
|
|
||||||
assertEquals(server, model.server)
|
|
||||||
assertEquals(Stage.Picker, model.stage)
|
|
||||||
assertEquals(MostRecentServer.None, recent.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking {
|
|
||||||
model.load(server)
|
|
||||||
coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401)
|
|
||||||
model.select(model.picker!!.person)
|
|
||||||
assertEquals(Stage.Login, model.stage)
|
|
||||||
assertEquals(user, model.loginUser)
|
|
||||||
assertNull(repository.current.value)
|
|
||||||
assertTrue(recent.get() is MostRecentServer.ServerAndUser)
|
|
||||||
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token"))
|
|
||||||
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
|
||||||
model.login(user.name!!, "test-password")
|
|
||||||
assertEquals("renewed-test-token", repository.current.value!!.user.accessToken)
|
|
||||||
assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
|
||||||
assertFalse(session.state!!.pickerVisible)
|
|
||||||
assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest {
|
|
||||||
model.load(server)
|
|
||||||
val saved = recent.get()
|
|
||||||
coEvery { usersApi.getCurrentUser() } throws IOException("offline")
|
|
||||||
model.select(model.picker!!.person)
|
|
||||||
assertEquals(Stage.Unavailable, model.stage)
|
|
||||||
assertEquals(saved, recent.get())
|
|
||||||
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
|
||||||
assertEquals(Stage.Loading, model.stage)
|
|
||||||
retry.await()
|
|
||||||
assertEquals(Stage.Picker, model.stage)
|
|
||||||
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
|
||||||
model.select(model.picker!!.person)
|
|
||||||
assertEquals(user.id, repository.current.value!!.user.id)
|
|
||||||
assertFalse(session.state!!.pickerVisible)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest {
|
|
||||||
database.serverDao().deleteUser(server.id, user.id)
|
|
||||||
val saved = recent.get()
|
|
||||||
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
|
||||||
model.load(null)
|
|
||||||
assertEquals(Stage.Unavailable, model.stage)
|
|
||||||
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
|
||||||
assertEquals(Stage.Loading, model.stage)
|
|
||||||
retry.await()
|
|
||||||
assertEquals(Stage.Unavailable, model.stage)
|
|
||||||
assertEquals(server, model.server)
|
|
||||||
assertEquals(saved, recent.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking {
|
|
||||||
user = user.copy(pin = "URDL")
|
|
||||||
database.serverDao().updateUser(user)
|
|
||||||
model.load(server)
|
|
||||||
model.select(model.picker!!.person)
|
|
||||||
assertEquals(user, model.pinUser)
|
|
||||||
assertTrue(model.picker!!.pickerVisible)
|
|
||||||
model.enterPin("WRONG")
|
|
||||||
assertNull(repository.current.value)
|
|
||||||
model.enterPin("URDL")
|
|
||||||
assertEquals(user.id, repository.current.value!!.user.id)
|
|
||||||
user = user.copy(pin = null, requireLogin = true)
|
|
||||||
database.serverDao().updateUser(user)
|
|
||||||
model.load(server)
|
|
||||||
model.select(model.picker!!.person)
|
|
||||||
assertEquals(Stage.Login, model.stage)
|
|
||||||
assertEquals(user, model.loginUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking {
|
|
||||||
database.serverDao().deleteUser(server.id, user.id)
|
|
||||||
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
|
||||||
model.load(null)
|
|
||||||
assertEquals(Stage.Unavailable, model.stage)
|
|
||||||
assertEquals(server, model.server)
|
|
||||||
model.showLogin(null)
|
|
||||||
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(
|
|
||||||
user = dto, serverId = server.id.toString(), accessToken = "new-test-token"))
|
|
||||||
model.login(user.name!!, "test-password")
|
|
||||||
assertEquals(user.id, repository.current.value!!.user.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking {
|
|
||||||
WolfskinScreenPreference.initialize(context)
|
|
||||||
val previous = WolfskinScreenPreference.enabled
|
|
||||||
try {
|
|
||||||
WolfskinScreenPreference.enabled = true
|
|
||||||
val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser
|
|
||||||
var destination: SetupDestination = SetupDestination.AppContent(expected)
|
|
||||||
every { setup.navigateTo(any()) } answers { destination = firstArg() }
|
|
||||||
recoverWolfskinSession(context, IOException("offline"), expected, repository, setup)
|
|
||||||
assertTrue(destination is SetupDestination.AppContent)
|
|
||||||
WolfskinScreenPreference.enabled = false
|
|
||||||
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
|
||||||
assertTrue(destination is SetupDestination.AppContent)
|
|
||||||
WolfskinScreenPreference.enabled = true
|
|
||||||
val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel"))
|
|
||||||
recent.save(CurrentUser(server, other))
|
|
||||||
repository.restoreSavedSession()
|
|
||||||
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
|
||||||
assertTrue(destination is SetupDestination.AppContent)
|
|
||||||
recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup)
|
|
||||||
model.load((destination as SetupDestination.UserList).server)
|
|
||||||
assertEquals(Stage.Picker, model.stage)
|
|
||||||
assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() })
|
|
||||||
assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
|
||||||
} finally { WolfskinScreenPreference.enabled = previous }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking {
|
|
||||||
database.serverDao().deleteUser(server.id, user.id)
|
|
||||||
database.serverDao().deleteServer(server.id)
|
|
||||||
recent.clear()
|
|
||||||
model.load(null)
|
|
||||||
assertEquals(Stage.Unconfigured, model.stage)
|
|
||||||
coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList()
|
|
||||||
model.connect("bad-url")
|
|
||||||
assertEquals(Stage.Unconfigured, model.stage)
|
|
||||||
assertTrue(database.serverDao().getServers().isEmpty())
|
|
||||||
coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo(
|
|
||||||
address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(),
|
|
||||||
systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true))))
|
|
||||||
model.connect(server.url)
|
|
||||||
assertEquals(server.id, database.serverDao().getServers().single().server.id)
|
|
||||||
coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto))
|
|
||||||
model.load(model.server)
|
|
||||||
assertEquals(Stage.Picker, model.stage)
|
|
||||||
model.select(model.picker!!.person)
|
|
||||||
assertEquals(Stage.Login, model.stage)
|
|
||||||
assertNull(repository.current.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,455 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import java.io.File
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
|
||||||
|
/** Exercises the production transport, verification store and state machine against HTTP bytes. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class HouseholdUpdateControllerTest {
|
||||||
|
@get:Rule val temporary = TemporaryFolder()
|
||||||
|
private lateinit var server: HttpServer
|
||||||
|
private lateinit var directory: File
|
||||||
|
private lateinit var base: String
|
||||||
|
private val apk = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||||
|
private var code = 100001L
|
||||||
|
private var releaseStatus = 200
|
||||||
|
private var assetStatus = 200
|
||||||
|
private var wireTruncated = false
|
||||||
|
private var metadataTruncated = false
|
||||||
|
private var listStatus = 200
|
||||||
|
private var listBody = "[]"
|
||||||
|
private var invalidJson = false
|
||||||
|
private var badHash = false
|
||||||
|
private var truncated = false
|
||||||
|
private var chunked = false
|
||||||
|
private var unsupported = false
|
||||||
|
private var manifestSigner = UpdateChannel.SIGNER
|
||||||
|
private var redirect = false
|
||||||
|
private var redirectTarget: String? = null
|
||||||
|
private val requests = mutableListOf<Map<String, List<String>>>()
|
||||||
|
private val platform = Platform()
|
||||||
|
|
||||||
|
private inner class Platform : UpdatePlatform {
|
||||||
|
var archive = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.1", 100001), setOf(UpdateChannel.SIGNER))
|
||||||
|
var installed = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.0", 65), setOf(UpdateChannel.SIGNER))
|
||||||
|
var permission = true
|
||||||
|
var settingsAvailable = true
|
||||||
|
var installs = 0
|
||||||
|
var settingsOpens = 0
|
||||||
|
var inspectAction: (() -> Unit)? = null
|
||||||
|
var outcome = InstallOutcome.Cancelled
|
||||||
|
override fun installed() = installed
|
||||||
|
override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive }
|
||||||
|
override fun hasInstallPermission() = permission
|
||||||
|
override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable }
|
||||||
|
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
|
||||||
|
}
|
||||||
|
private val preferences = object : UpdatePreferences {
|
||||||
|
override val autoCheck = MutableStateFlow(true)
|
||||||
|
override suspend fun url() = "$base/releases/latest"
|
||||||
|
override suspend fun enabled() = autoCheck.value
|
||||||
|
override suspend fun setAutoCheck(enabled: Boolean) { autoCheck.value = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before fun start() {
|
||||||
|
directory = temporary.newFolder()
|
||||||
|
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||||
|
base = "http://127.0.0.1:${server.address.port}"
|
||||||
|
server.createContext("/") { exchange ->
|
||||||
|
synchronized(requests) { requests += exchange.requestHeaders.mapValues { it.value.toList() } }
|
||||||
|
val path = exchange.requestURI.path
|
||||||
|
val status = when (path) {
|
||||||
|
"/releases/latest" -> releaseStatus
|
||||||
|
"/releases" -> listStatus
|
||||||
|
"/apk" -> assetStatus
|
||||||
|
else -> 200
|
||||||
|
}
|
||||||
|
val content = when (path) {
|
||||||
|
"/releases/latest", "/redirected" -> if (invalidJson) "not json" else """{"name":"v1.1.1","body":"Household notes","published_at":"2026-11-07T12:00:00Z","assets":[{"name":"wolfskin-update.json","browser_download_url":"$base/manifest"},{"name":"Wolfskin-armeabi-v7a.apk","browser_download_url":"$base/apk"}]}"""
|
||||||
|
"/manifest" -> UpdateManifest.encode(manifest())
|
||||||
|
"/releases" -> listBody
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
if (redirect && path == "/releases/latest") {
|
||||||
|
exchange.responseHeaders.add("Location", redirectTarget ?: "$base/redirected")
|
||||||
|
exchange.responseHeaders.add("Set-Cookie", "private-session=never-forward")
|
||||||
|
exchange.sendResponseHeaders(302, -1)
|
||||||
|
} else {
|
||||||
|
val bytes = if (path == "/apk") (if (truncated) apk.copyOf(apk.size / 2) else apk) else content.toByteArray()
|
||||||
|
val length = when {
|
||||||
|
chunked && path == "/apk" -> 0L
|
||||||
|
wireTruncated && path == "/apk" -> apk.size.toLong()
|
||||||
|
metadataTruncated && path == "/releases/latest" -> bytes.size + 1L
|
||||||
|
else -> bytes.size.toLong()
|
||||||
|
}
|
||||||
|
exchange.sendResponseHeaders(status, length)
|
||||||
|
exchange.responseBody.use { it.write(bytes) }
|
||||||
|
}
|
||||||
|
exchange.close()
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
}
|
||||||
|
@After fun stop() { server.stop(0) }
|
||||||
|
|
||||||
|
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
|
||||||
|
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", manifestSigner,
|
||||||
|
listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(),
|
||||||
|
if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23)))
|
||||||
|
|
||||||
|
private fun controller(scope: kotlinx.coroutines.CoroutineScope) = HouseholdUpdateController(scope, preferences, platform,
|
||||||
|
UpdateTransport(allowLocalTestHttp = true), VerifiedUpdateStore(directory, platform), listOf("armeabi-v7a"), 30)
|
||||||
|
|
||||||
|
@Test fun newerReleaseDownloadsVerifiesAndRestoresWithoutInstalling() = runTest {
|
||||||
|
val controller = controller(this)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||||
|
val metadata = controller.release.value
|
||||||
|
assertEquals(controller.state.value, metadata)
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
assertEquals(metadata, controller.release.value)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
val restored = controller(this); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
|
||||||
|
assertEquals(metadata, restored.release.value)
|
||||||
|
restored.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
assertTrue(File(directory, "verified.apk").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest {
|
||||||
|
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
|
||||||
|
val metadata = old.release.value!!
|
||||||
|
assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText()))
|
||||||
|
File(directory, "ready.json").writeText(UpdateManifest.encode(manifest()))
|
||||||
|
val recreated = controller(this); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value)
|
||||||
|
assertEquals(metadata.copy(notes = ""), recreated.release.value)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
|
||||||
|
code = 65
|
||||||
|
val equal = controller(this); equal.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.UpToDate, equal.state.value)
|
||||||
|
code = 64
|
||||||
|
val older = controller(this); older.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.LowerVersionCode, (older.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun emptyChannelIsDistinctFromMissingOrRefusedEndpoint() = runTest {
|
||||||
|
releaseStatus = 404
|
||||||
|
val empty = controller(this); empty.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.NoReleasePublished, empty.state.value)
|
||||||
|
for (status in listOf(401, 403, 404, 500, 503)) {
|
||||||
|
listStatus = status
|
||||||
|
val failure = controller(this); failure.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.HttpRefused, (failure.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refusedLatestNeverBecomesUpToDate() = runTest {
|
||||||
|
for (status in listOf(401, 403, 500, 503)) {
|
||||||
|
releaseStatus = status
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun invalidJsonAndUnavailableAbiAreTypedFailures() = runTest {
|
||||||
|
invalidJson = true
|
||||||
|
val invalid = controller(this); invalid.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.InvalidMetadata, (invalid.state.value as UpdateUiState.Failed).kind)
|
||||||
|
invalidJson = false; unsupported = true
|
||||||
|
val unsupported = controller(this); unsupported.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest {
|
||||||
|
manifestSigner = "b".repeat(64)
|
||||||
|
val controller = controller(this)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
|
||||||
|
for (badChecksum in listOf(true, false)) {
|
||||||
|
badHash = badChecksum; truncated = !badChecksum
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.ChecksumMismatch, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun wrongPackageSignerAndArchiveCodeCannotBecomeReady() = runTest {
|
||||||
|
val original = platform.archive
|
||||||
|
for ((archive, expected) in listOf(
|
||||||
|
original.copy(packageId = "other.package") to UpdateFailure.WrongPackage,
|
||||||
|
original.copy(signers = setOf("b".repeat(64))) to UpdateFailure.WrongSigner,
|
||||||
|
original.copy(version = InstalledVersion("1.1.1", 65)) to UpdateFailure.LowerVersionCode,
|
||||||
|
)) {
|
||||||
|
platform.archive = archive
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(expected, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun chunkedTransferReportsUnknownTotalAndCancelDeletesPartial() = runTest {
|
||||||
|
chunked = true
|
||||||
|
val controller = controller(this)
|
||||||
|
var observedUnknownLength = false
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
controller.state.collect { state ->
|
||||||
|
if (state is UpdateUiState.Downloading && state.bytesDone > 0) {
|
||||||
|
observedUnknownLength = state.bytesTotal == null
|
||||||
|
controller.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertTrue(observedUnknownLength)
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun redirectRequestsContainNoCredentialsAndRejectUrlUserInfo() = runTest {
|
||||||
|
redirect = true
|
||||||
|
redirectTarget = "http://localhost:${server.address.port}/redirected"
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||||
|
assertTrue(requests.size >= 3)
|
||||||
|
requests.forEach { headers ->
|
||||||
|
assertFalse(headers.keys.any { it.equals("Authorization", true) || it.equals("Cookie", true) ||
|
||||||
|
it.contains("Jellyfin", true) || it.contains("Emby", true) || it.contains("MediaBrowser", true) })
|
||||||
|
}
|
||||||
|
redirectTarget = "http://user:password@127.0.0.1:${server.address.port}/redirected"
|
||||||
|
val rejected = controller(this); rejected.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.InvalidMetadata, (rejected.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun manualCheckWorksWithAutoOffAndPermissionDenialKeepsReady() = runTest {
|
||||||
|
preferences.autoCheck.value = false
|
||||||
|
val controller = controller(this)
|
||||||
|
controller.check(false); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Idle, controller.state.value)
|
||||||
|
controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.permission = false
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
controller.onResume(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
platform.settingsAvailable = false
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.NoInstaller, controller.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
val metadata = controller.release.value
|
||||||
|
platform.permission = false
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(2, platform.settingsOpens)
|
||||||
|
controller.cancel(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
assertEquals(metadata, controller.release.value)
|
||||||
|
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
assertEquals(3, platform.settingsOpens)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.outcome = InstallOutcome.Pending
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
controller.cancel(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
val before = requests.size
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
assertEquals(before, requests.size)
|
||||||
|
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||||
|
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(2, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.inspectAction = { controller.cancel() }
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
File(directory, "verified.apk").appendText("corrupt")
|
||||||
|
File(directory, "download.part").writeText("incomplete")
|
||||||
|
val recreated = controller(this); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Idle, recreated.state.value)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun downloadHttpFailureRetriesWithoutFalseSuccess() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assetStatus = 403
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assetStatus = 200
|
||||||
|
controller.retry(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest {
|
||||||
|
val controller = controller(this)
|
||||||
|
releaseStatus = 403
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation)
|
||||||
|
releaseStatus = 200
|
||||||
|
controller.retry(); advanceUntilIdle()
|
||||||
|
assetStatus = 403
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation)
|
||||||
|
assetStatus = 200
|
||||||
|
controller.retry(); advanceUntilIdle()
|
||||||
|
File(directory, "verified.apk").writeBytes(ByteArray(apk.size))
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
val failed = controller.state.value as UpdateUiState.Failed
|
||||||
|
assertEquals(UpdateOperation.Install, failed.operation)
|
||||||
|
assertEquals(UpdateFailure.ChecksumMismatch, failed.kind)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
|
||||||
|
truncated = true; wireTruncated = true
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Failed)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun exhaustedStorageFailsBeforeDownloading() = runTest {
|
||||||
|
val fullDirectory = object : File(directory.absolutePath) { override fun getUsableSpace(): Long = 0 }
|
||||||
|
val controller = HouseholdUpdateController(this, preferences, platform, UpdateTransport(true),
|
||||||
|
VerifiedUpdateStore(fullDirectory, platform), listOf("armeabi-v7a"), 30)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
val before = requests.size
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.InsufficientStorage, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertEquals(before, requests.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun successCallbackRefreshesPackageManagerInsteadOfClaimingAnUpgrade() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.outcome = InstallOutcome.Success
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(65L, controller.installedVersion.value.versionCode)
|
||||||
|
assertTrue(File(directory, "verified.apk").exists())
|
||||||
|
controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
platform.installed = platform.archive
|
||||||
|
controller.onResume(); advanceUntilIdle()
|
||||||
|
assertEquals(100001L, controller.installedVersion.value.versionCode)
|
||||||
|
assertEquals(UpdateUiState.Idle, controller.state.value)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun successCallbackDeliveredToTheReplacedProcessDoesNotLeaveAFalseHandoff() = runTest {
|
||||||
|
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
|
||||||
|
platform.installed = platform.archive
|
||||||
|
val replaced = controller(this); advanceUntilIdle()
|
||||||
|
replaced.handleInstallOutcome(InstallOutcome.Success); advanceUntilIdle()
|
||||||
|
assertEquals(100001L, replaced.installedVersion.value.versionCode)
|
||||||
|
assertEquals(UpdateUiState.Idle, replaced.state.value)
|
||||||
|
replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Idle, replaced.state.value)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
replaced.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.UpToDate, replaced.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.outcome = InstallOutcome.Pending
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
val before = requests.size
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.HandingOff, controller.state.value)
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
assertEquals(before, requests.size)
|
||||||
|
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
|
||||||
|
assertTrue(File(directory, "verified.apk").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun productionTransportRefusesCleartextBeforeRequesting() = runTest {
|
||||||
|
try {
|
||||||
|
UpdateTransport().latest("$base/releases/latest", listOf("armeabi-v7a"), 30)
|
||||||
|
fail("Cleartext should be refused")
|
||||||
|
} catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
|
||||||
|
assertTrue(requests.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unavailableNetworkIsOfflineNotUpToDate() = runTest {
|
||||||
|
server.stop(0)
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Offline, controller.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun interruptedFirstCheckIsNetworkFailureNotMissingDownloadStorage() = runTest {
|
||||||
|
directory = File(directory, "not-created")
|
||||||
|
metadataTruncated = true
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.Network, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(directory.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun compatibleArm32IsPreferredAndUniversalRespectsMinimumSdk() {
|
||||||
|
val arm32 = manifest().assets.single()
|
||||||
|
val arm64 = arm32.copy(abi = "arm64-v8a", name = "Wolfskin-arm64-v8a.apk")
|
||||||
|
val universal = arm32.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 31)
|
||||||
|
val manifest = manifest().copy(assets = listOf(arm64, arm32, universal))
|
||||||
|
assertEquals(arm32, manifest.selectAsset(listOf("arm64-v8a", "armeabi-v7a"), 30))
|
||||||
|
try { manifest.selectAsset(listOf("unknown"), 30); fail("Minimum SDK must be enforced") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
|
||||||
|
/** Behavior boundaries are controller outcomes, not incidental UI wording. */
|
||||||
|
class UpdateBehaviorTest {
|
||||||
|
@Test fun everyOutcomeHasOnlyExplicitUserActions() {
|
||||||
|
val expected = listOf(
|
||||||
|
UpdateUiState.Idle to listOf(UpdateCommand.Check),
|
||||||
|
UpdateUiState.Checking to listOf(UpdateCommand.Back),
|
||||||
|
UpdateUiState.UpToDate to listOf(UpdateCommand.Check),
|
||||||
|
UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check),
|
||||||
|
updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes),
|
||||||
|
UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel),
|
||||||
|
UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||||
|
UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel),
|
||||||
|
UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel),
|
||||||
|
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||||
|
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
|
||||||
|
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
|
||||||
|
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
|
||||||
|
expected.forEach { (state, commands) ->
|
||||||
|
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
|
||||||
|
}
|
||||||
|
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
|
||||||
|
val installed = "1.1.1"
|
||||||
|
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
|
||||||
|
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
|
||||||
|
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
|
||||||
|
assertEquals(presentUpdate(UpdateUiState.Checking, null),
|
||||||
|
presentUpdate(UpdateUiState.Checking, null, installed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
|
||||||
|
listOf(null, 0L, -1L).forEach { total ->
|
||||||
|
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
||||||
|
assertNull(ui.fraction)
|
||||||
|
}
|
||||||
|
assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
|
||||||
|
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() {
|
||||||
|
val controller = FakeUpdateController(updateReleaseFixture())
|
||||||
|
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||||
|
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||||
|
focus.key(ShellKey.Right)
|
||||||
|
focus.key(ShellKey.Down)
|
||||||
|
assertTrue(focus.autoFocused)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
focus.key(ShellKey.Ok)
|
||||||
|
assertFalse(controller.autoCheck.value)
|
||||||
|
focus.key(ShellKey.Up)
|
||||||
|
assertFalse(focus.autoFocused)
|
||||||
|
assertEquals(1, focus.actionIndex)
|
||||||
|
assertFalse(controller.autoCheck.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun notesTrapFocusAndBackRestoresNotesAction() {
|
||||||
|
val controller = FakeUpdateController(updateReleaseFixture())
|
||||||
|
var backs = 0
|
||||||
|
var navs = 0
|
||||||
|
val steps = mutableListOf<Int>()
|
||||||
|
val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add)
|
||||||
|
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||||
|
focus.key(ShellKey.Right); focus.key(ShellKey.Ok)
|
||||||
|
listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key)
|
||||||
|
assertTrue(focus.notesOpen)
|
||||||
|
assertEquals(listOf(-1, 1), steps)
|
||||||
|
assertEquals(0, navs)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
focus.key(ShellKey.Back)
|
||||||
|
assertFalse(focus.notesOpen)
|
||||||
|
assertEquals(1, focus.actionIndex)
|
||||||
|
assertEquals(0, backs)
|
||||||
|
focus.key(ShellKey.Up)
|
||||||
|
assertEquals(1, navs)
|
||||||
|
focus.key(ShellKey.Back)
|
||||||
|
assertEquals(1, backs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() {
|
||||||
|
val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100))
|
||||||
|
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||||
|
fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) }
|
||||||
|
update(controller.state.value)
|
||||||
|
focus.key(ShellKey.Down)
|
||||||
|
update(UpdateUiState.Downloading(90, 100))
|
||||||
|
assertTrue(focus.autoFocused)
|
||||||
|
update(UpdateUiState.Ready("1.1.1"))
|
||||||
|
assertFalse(focus.autoFocused)
|
||||||
|
focus.key(ShellKey.Right)
|
||||||
|
update(UpdateUiState.PermissionNeeded)
|
||||||
|
update(UpdateUiState.InstallCancelled)
|
||||||
|
assertEquals(0, focus.actionIndex)
|
||||||
|
assertFalse(focus.autoFocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same release facts as the approved television frames. */
|
||||||
|
fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026",
|
||||||
|
"Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.")
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Malformed channel metadata must never become a compatible version comparison. */
|
||||||
|
class UpdateManifestTest {
|
||||||
|
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", 128, "a".repeat(64), 23)
|
||||||
|
private val manifest = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", 100001,
|
||||||
|
"2026-11-07T12:00:00Z", "b".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER, listOf(asset))
|
||||||
|
|
||||||
|
@Test fun malformedManifestFieldsAreRejectedBeforeVersionComparison() {
|
||||||
|
val invalid = listOf(
|
||||||
|
manifest.copy(schemaVersion = 2),
|
||||||
|
manifest.copy(versionName = "1.1.1-opt"),
|
||||||
|
manifest.copy(versionCode = 0),
|
||||||
|
manifest.copy(publishedAt = "yesterday"),
|
||||||
|
manifest.copy(sourceCommit = "main"),
|
||||||
|
manifest.copy(sourceTag = ""),
|
||||||
|
manifest.copy(signerSha256 = "not-a-certificate-fingerprint"),
|
||||||
|
manifest.copy(assets = emptyList()),
|
||||||
|
manifest.copy(assets = listOf(asset.copy(bytes = 0))),
|
||||||
|
manifest.copy(assets = listOf(asset.copy(sha256 = "not-a-checksum"))),
|
||||||
|
manifest.copy(assets = listOf(asset.copy(abi = "arm64-v8a"))),
|
||||||
|
manifest.copy(assets = listOf(asset, asset)),
|
||||||
|
)
|
||||||
|
invalid.forEach { candidate ->
|
||||||
|
try { UpdateManifest.parse(UpdateManifest.encode(candidate)); fail("Invalid manifest was accepted: $candidate") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun fallbackRequiresACompatibleStandaloneAsset() {
|
||||||
|
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 35)
|
||||||
|
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
|
||||||
|
assertEquals(universal, parsed.selectAsset(listOf("arm64-v8a"), 35))
|
||||||
|
try { parsed.selectAsset(listOf("arm64-v8a"), 34); fail("Unsupported minimum SDK was accepted") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun universalDoesNotMakeAnUnsupportedProcessorCompatible() {
|
||||||
|
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk")
|
||||||
|
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
|
||||||
|
try { parsed.selectAsset(listOf("riscv64"), 34); fail("Unsupported processor was accepted") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** A replacement or incomplete stream must never reach the session commit boundary. */
|
||||||
|
class UpdateSessionStreamTest {
|
||||||
|
private val original = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||||
|
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", original.size.toLong(),
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(original).toHexString(), 23)
|
||||||
|
|
||||||
|
@Test fun sameLengthReplacementAfterVerificationIsRejected() {
|
||||||
|
val replaced = original.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() }
|
||||||
|
expectMismatch(replaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun truncatedAndOversizedSessionStreamsAreRejected() {
|
||||||
|
expectMismatch(original.copyOf(original.size - 1))
|
||||||
|
expectMismatch(original + byteArrayOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectMismatch(bytes: ByteArray) {
|
||||||
|
try {
|
||||||
|
bytes.inputStream().use { copyVerifiedUpdate(it, ByteArrayOutputStream(), asset) }
|
||||||
|
fail("Changed installation bytes were accepted")
|
||||||
|
} catch (error: UpdateException) {
|
||||||
|
assertEquals(UpdateFailure.ChecksumMismatch, error.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class UpdatesKeysTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
|
||||||
|
pickerVisible = false
|
||||||
|
enterDestination("updates")
|
||||||
|
settingsRow = 1
|
||||||
|
}
|
||||||
|
private lateinit var controller: FakeUpdateController
|
||||||
|
private val reduced = mutableStateOf(false)
|
||||||
|
private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File)
|
||||||
|
|
||||||
|
@Before fun fullscreen() {
|
||||||
|
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) {
|
||||||
|
controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture())
|
||||||
|
if (settings) shell.enterDestination("settings")
|
||||||
|
rule.setContent {
|
||||||
|
CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
|
||||||
|
WolfskinTheme {
|
||||||
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
||||||
|
settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier ->
|
||||||
|
UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String) {
|
||||||
|
val target = directory ?: return
|
||||||
|
target.mkdirs()
|
||||||
|
rule.waitForIdle()
|
||||||
|
val file = File(target, "$name.png")
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(keys: List<Key>) {
|
||||||
|
keys.forEach { key ->
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||||
|
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
|
||||||
|
shell.settingsRow = 0
|
||||||
|
launch(UpdateUiState.Checking, settings = true)
|
||||||
|
repeat(9) { press(listOf(Key.DirectionDown)) }
|
||||||
|
assertEquals(9, shell.settingsRow)
|
||||||
|
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
|
||||||
|
capture("classic")
|
||||||
|
press(listOf(Key.DirectionCenter))
|
||||||
|
assertEquals("classic", shell.handOff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
|
||||||
|
launch(updateReleaseFixture())
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown))
|
||||||
|
assertTrue(shell.contentOverlayVisible)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
press(listOf(Key.Back))
|
||||||
|
assertFalse(shell.contentOverlayVisible)
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
press(listOf(Key.Back))
|
||||||
|
assertEquals("settings", shell.route)
|
||||||
|
assertEquals(1, shell.settingsRow)
|
||||||
|
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() {
|
||||||
|
launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" }))
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown))
|
||||||
|
val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange]
|
||||||
|
assertTrue(range.value() > 0f)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
|
||||||
|
press(listOf(Key.Back))
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
|
||||||
|
launch(updateReleaseFixture())
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
press(listOf(Key.DirectionUp))
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
press(listOf(Key.DirectionDown))
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
|
||||||
|
launch(UpdateUiState.UpToDate)
|
||||||
|
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.runOnIdle { controller.show(UpdateUiState.Checking) }
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.onNodeWithTag("update-progress").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
|
||||||
|
launch(UpdateUiState.Checking)
|
||||||
|
val target = directory
|
||||||
|
if (target != null) {
|
||||||
|
repeat(113) { frame ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
capture("motion/checking-%03d".format(frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
|
||||||
|
if (target != null) {
|
||||||
|
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
|
||||||
|
}
|
||||||
|
press(listOf(Key.DirectionDown))
|
||||||
|
rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) }
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||||
|
rule.runOnIdle { reduced.value = true }
|
||||||
|
rule.mainClock.advanceTimeBy(96)
|
||||||
|
capture("motion/reduced-000")
|
||||||
|
rule.mainClock.advanceTimeBy(1600)
|
||||||
|
capture("motion/reduced-100")
|
||||||
|
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,7 @@ 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
|
||||||
|
|
@ -195,7 +196,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" -> navigation.navigateTo(target)
|
action.route == "settings" || action.route == "updates" -> 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)
|
||||||
}
|
}
|
||||||
|
|
@ -225,11 +226,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
if (saved == null || saved.isProtected) {
|
if (saved == null || saved.isProtected) return@launch
|
||||||
if (ConductarrPreference.fixtures) return@launch
|
|
||||||
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
val current = repository.changeUser(server, saved)
|
val current = repository.changeUser(server, saved)
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|
@ -237,7 +234,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +263,7 @@ 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) {
|
||||||
|
|
@ -307,7 +305,13 @@ 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(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
NavEntry(
|
||||||
|
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
|
||||||
|
|
@ -333,11 +337,15 @@ 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,22 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.github.damontecres.wholphin.data.CurrentUser
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
|
||||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
|
||||||
|
|
||||||
/** Only an expired current login leaves saved content; outages and late failures keep it usable. */
|
|
||||||
fun recoverWolfskinSession(
|
|
||||||
context: Context,
|
|
||||||
exception: Exception,
|
|
||||||
expected: CurrentUser?,
|
|
||||||
repository: ServerRepository,
|
|
||||||
setup: SetupNavigationManager,
|
|
||||||
) {
|
|
||||||
if (exception !is InvalidStatusException || exception.status != 401 || expected == null || repository.current.value !== expected) return
|
|
||||||
WolfskinScreenPreference.initialize(context)
|
|
||||||
if (WolfskinScreenPreference.enabled) setup.navigateTo(SetupDestination.UserList(expected.server))
|
|
||||||
}
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
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.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.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.tv.material3.Button
|
|
||||||
import androidx.tv.material3.ButtonDefaults
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
|
||||||
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.PinEntryDialog
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.justinvisser.wolfskin.design.ShellAction
|
|
||||||
import org.justinvisser.wolfskin.design.ShellSurface
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
|
||||||
import org.justinvisser.wolfskin.design.Wordmark
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
|
|
||||||
|
|
||||||
/** Intercept both stock setup destinations before either upstream selector can be composed. */
|
|
||||||
@Composable
|
|
||||||
fun WolfskinSetupContent(destination: SetupDestination, modifier: Modifier = Modifier, model: WolfskinSetupViewModel? = null): Boolean {
|
|
||||||
if (destination != SetupDestination.ServerList && destination !is SetupDestination.UserList) return false
|
|
||||||
val context = LocalContext.current
|
|
||||||
remember(context) {
|
|
||||||
WolfskinScreenPreference.initialize(context)
|
|
||||||
ConductarrPreference.initialize(context)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
if (!WolfskinScreenPreference.enabled) return false
|
|
||||||
WolfskinSetupScreen(destination, modifier, model ?: hiltViewModel())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Saved people remain local; network failures expose retry and real server authentication. */
|
|
||||||
@Composable
|
|
||||||
internal fun WolfskinSetupScreen(
|
|
||||||
destination: SetupDestination,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
model: WolfskinSetupViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val hint = (destination as? SetupDestination.UserList)?.server
|
|
||||||
LaunchedEffect(destination) { model.load(hint) }
|
|
||||||
val retrySetup: () -> Unit = { scope.launch { model.retry() } }
|
|
||||||
model.onAction = { action ->
|
|
||||||
when (action) {
|
|
||||||
is ShellAction.Person -> scope.launch { model.select(action.person) }
|
|
||||||
is ShellAction.Destination -> model.showSettings()
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackHandler {
|
|
||||||
if (model.pinUser != null) model.dismissPin()
|
|
||||||
else model.returnToPicker()
|
|
||||||
}
|
|
||||||
WolfskinTheme {
|
|
||||||
val picker = model.picker
|
|
||||||
if (model.stage == Stage.Picker && picker != null) {
|
|
||||||
ShellSurface(picker, modifier.testTag("wolfskin-setup-picker"))
|
|
||||||
} else {
|
|
||||||
SetupPanel(model.stage, modifier) {
|
|
||||||
when (model.stage) {
|
|
||||||
Stage.Loading -> Text("Reading saved profiles…", color = C.muted, style = WolfskinTokens.Type.body)
|
|
||||||
Stage.Unconfigured -> ServerForm(model)
|
|
||||||
Stage.Login -> LoginForm(model)
|
|
||||||
Stage.Settings -> {
|
|
||||||
SetupButton("Sign in to another profile", !model.busy, initialFocus = true) { model.showLogin(null) }
|
|
||||||
SetupButton("Back to profiles", !model.busy, onClick = model::returnToPicker)
|
|
||||||
}
|
|
||||||
Stage.Unavailable -> {
|
|
||||||
Text(model.message.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
|
||||||
SetupButton("Try again", !model.busy, initialFocus = true, onClick = retrySetup)
|
|
||||||
if (model.server != null) SetupButton("Sign in", !model.busy) { model.showLogin(null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Stage.Picker -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
model.pinUser?.let { user ->
|
|
||||||
PinEntryDialog(
|
|
||||||
onDismissRequest = model::dismissPin,
|
|
||||||
onClickServerAuth = { model.showLogin(user) },
|
|
||||||
onTextChange = { value -> scope.launch { model.enterPin(value) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SetupPanel(stage: Stage, modifier: Modifier, content: @Composable () -> Unit) {
|
|
||||||
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-setup-${stage.name.lowercase()}")) {
|
|
||||||
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
|
||||||
Column(Modifier.align(Alignment.Center).width(S.pickerErrorPanelWidth),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
|
|
||||||
Text(when (stage) {
|
|
||||||
Stage.Unconfigured -> "Connect your household server"
|
|
||||||
Stage.Login -> "Sign in to your profile"
|
|
||||||
Stage.Settings -> "Settings"
|
|
||||||
else -> "Who’s watching?"
|
|
||||||
}, color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ServerForm(model: WolfskinSetupViewModel) {
|
|
||||||
var url by remember { mutableStateOf("") }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val focus = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
|
||||||
Text("Enter your Jellyfin server URL once. Wolfskin will remember it on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
|
|
||||||
EditTextBox(value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("household-server-url"),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
|
||||||
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
|
|
||||||
SetupButton(if (model.busy) "Connecting…" else "Try again", !model.busy && url.isNotBlank()) {
|
|
||||||
scope.launch { model.connect(url) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LoginForm(model: WolfskinSetupViewModel) {
|
|
||||||
var username by remember(model.loginUser) { mutableStateOf(model.loginUser?.name.orEmpty()) }
|
|
||||||
var password by remember(model.loginUser) { mutableStateOf("") }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val focus = remember { FocusRequester() }
|
|
||||||
val initialUsername = remember(model.loginUser) { username.isBlank() }
|
|
||||||
LaunchedEffect(model.loginUser) { focus.requestFocus() }
|
|
||||||
Text(model.server?.name ?: model.server?.url.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
|
|
||||||
Text("Username", color = C.fg, style = WolfskinTokens.Type.body)
|
|
||||||
EditTextBox(value = username, onValueChange = { username = it }, modifier = Modifier.fillMaxWidth()
|
|
||||||
.then(if (initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-username"))
|
|
||||||
Text("Password", color = C.fg, style = WolfskinTokens.Type.body)
|
|
||||||
EditTextBox(value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth()
|
|
||||||
.then(if (!initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-password"),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
|
||||||
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
|
|
||||||
SetupButton(if (model.busy) "Signing in…" else "Sign in", !model.busy && username.isNotBlank()) {
|
|
||||||
val submitted = password
|
|
||||||
password = ""
|
|
||||||
scope.launch { model.login(username, submitted) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SetupButton(label: String, enabled: Boolean, initialFocus: Boolean = false, onClick: () -> Unit) {
|
|
||||||
val focus = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) { if (initialFocus && enabled) focus.requestFocus() }
|
|
||||||
Button(onClick = onClick, enabled = enabled, modifier = Modifier.focusRequester(focus),
|
|
||||||
shape = ButtonDefaults.shape(shape = RoundedCornerShape(WolfskinTokens.Radius.button)),
|
|
||||||
colors = ButtonDefaults.colors(containerColor = C.buttonSecondary, contentColor = C.fg,
|
|
||||||
focusedContainerColor = C.fg, focusedContentColor = C.onFocus)) {
|
|
||||||
Text(label, style = WolfskinTokens.Type.button)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import com.github.damontecres.wholphin.data.RestoredSession
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import org.justinvisser.wolfskin.design.ShellAction
|
|
||||||
import org.justinvisser.wolfskin.design.ShellPerson
|
|
||||||
import org.justinvisser.wolfskin.design.ShellState
|
|
||||||
|
|
||||||
/** Setup never clears the remembered household endpoint or login merely to show the picker. */
|
|
||||||
@HiltViewModel
|
|
||||||
class WolfskinSetupViewModel @Inject constructor(
|
|
||||||
private val repository: ServerRepository,
|
|
||||||
private val navigation: NavigationManager,
|
|
||||||
private val setup: SetupNavigationManager,
|
|
||||||
private val session: WolfskinSession,
|
|
||||||
private val conductarr: ConductarrSession,
|
|
||||||
) : ViewModel() {
|
|
||||||
internal enum class Stage { Loading, Unconfigured, Picker, Login, Settings, Unavailable }
|
|
||||||
|
|
||||||
internal var stage by mutableStateOf(Stage.Loading)
|
|
||||||
private set
|
|
||||||
internal var server by mutableStateOf<JellyfinServer?>(null)
|
|
||||||
private set
|
|
||||||
internal var picker by mutableStateOf<ShellState?>(null)
|
|
||||||
private set
|
|
||||||
internal var message by mutableStateOf<String?>(null)
|
|
||||||
private set
|
|
||||||
internal var busy by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
internal var pinUser by mutableStateOf<JellyfinUser?>(null)
|
|
||||||
private set
|
|
||||||
internal var loginUser by mutableStateOf<JellyfinUser?>(null)
|
|
||||||
private set
|
|
||||||
private var users: List<JellyfinUser> = emptyList()
|
|
||||||
internal var onAction: (ShellAction) -> Unit = {}
|
|
||||||
|
|
||||||
/** Recover Room's saved household server even if the old selector erased its preference. */
|
|
||||||
internal suspend fun load(hint: JellyfinServer?) = perform {
|
|
||||||
conductarr.close()
|
|
||||||
server = hint ?: repository.current.value?.server ?: when (val recent = repository.getMostRecentServer()) {
|
|
||||||
is RestoredSession.ServerOnly -> recent.server
|
|
||||||
is RestoredSession.Success -> recent.currentUser.server
|
|
||||||
RestoredSession.None -> withContext(Dispatchers.IO) {
|
|
||||||
repository.serverDao.getServers().maxWithOrNull(compareBy { it.server.lastUsed })?.server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val household = server
|
|
||||||
if (household == null) {
|
|
||||||
stage = Stage.Unconfigured
|
|
||||||
return@perform
|
|
||||||
}
|
|
||||||
users = withContext(Dispatchers.IO) { repository.serverDao.getServer(household.id)?.users.orEmpty() }
|
|
||||||
if (users.isEmpty()) {
|
|
||||||
users = withContext(Dispatchers.IO) {
|
|
||||||
repository.jellyfin.createApi(household.url).userApi.getPublicUsers().content.map {
|
|
||||||
JellyfinUser(id = it.id, name = it.name, serverId = household.id, accessToken = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (users.isEmpty()) {
|
|
||||||
showLogin(null)
|
|
||||||
} else {
|
|
||||||
val people = users.map { ShellPerson(it.id.toString(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
|
|
||||||
picker = ShellState(people, people.first().key) { onAction(it) }
|
|
||||||
stage = Stage.Picker
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retry the startup server/profile recovery, never the failed saved-token request. */
|
|
||||||
internal suspend fun retry() {
|
|
||||||
if (busy) return
|
|
||||||
stage = Stage.Loading
|
|
||||||
load(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discovery and persistence use the same Jellyfin contract as upstream's add-server action. */
|
|
||||||
internal suspend fun connect(url: String) = perform {
|
|
||||||
val household = withContext(Dispatchers.IO) {
|
|
||||||
val candidate = repository.jellyfin.discovery.getRecommendedServers(url.trim())
|
|
||||||
.sortedBy { it.score }.firstOrNull { it.score != RecommendedServerInfoScore.BAD }
|
|
||||||
val info = candidate?.systemInfo?.getOrNull()
|
|
||||||
val id = info?.id?.toUUIDOrNull()
|
|
||||||
require(candidate != null && info != null && id != null && info.startupWizardCompleted == true) { "Could not connect to the household server. Check the URL and try again." }
|
|
||||||
JellyfinServer(id, info.serverName, candidate.address, info.version).also {
|
|
||||||
repository.addAndChangeServer(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
server = household
|
|
||||||
setup.navigateTo(SetupDestination.UserList(household))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Protected saved users must authenticate before changeUser is allowed to run. */
|
|
||||||
internal suspend fun select(person: ShellPerson) {
|
|
||||||
picker?.pickerVisible = true
|
|
||||||
if (busy) return
|
|
||||||
val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return
|
|
||||||
when {
|
|
||||||
selected.accessToken.isNullOrBlank() || selected.requireLogin -> showLogin(selected)
|
|
||||||
selected.hasPin -> pinUser = selected
|
|
||||||
else -> changeUser(selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun showLogin(user: JellyfinUser?) {
|
|
||||||
pinUser = null
|
|
||||||
loginUser = user
|
|
||||||
message = null
|
|
||||||
stage = Stage.Login
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun dismissPin() { pinUser = null }
|
|
||||||
|
|
||||||
internal fun showSettings() {
|
|
||||||
picker?.pickerVisible = true
|
|
||||||
if (!busy) stage = Stage.Settings
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun returnToPicker() {
|
|
||||||
if (busy || picker == null) return
|
|
||||||
picker?.pickerVisible = true
|
|
||||||
stage = Stage.Picker
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun enterPin(value: String) {
|
|
||||||
val selected = pinUser ?: return
|
|
||||||
if (value == selected.pin) {
|
|
||||||
pinUser = null
|
|
||||||
changeUser(selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun changeUser(user: JellyfinUser) = perform {
|
|
||||||
try {
|
|
||||||
complete(repository.changeUser(requireNotNull(server), user))
|
|
||||||
} catch (exception: InvalidStatusException) {
|
|
||||||
if (exception.status != 401) throw exception
|
|
||||||
showLogin(user)
|
|
||||||
message = "Your saved sign-in expired. Sign in again to continue."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A password is sent only to the saved household server and never retained by this model. */
|
|
||||||
internal suspend fun login(username: String, password: String) = perform {
|
|
||||||
val household = requireNotNull(server)
|
|
||||||
val current = withContext(Dispatchers.IO) {
|
|
||||||
val result = repository.jellyfin.createApi(household.url).userApi
|
|
||||||
.authenticateUserByName(username = username, password = password).content
|
|
||||||
repository.changeUser(household.url, result, loginUser)
|
|
||||||
}
|
|
||||||
complete(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun complete(current: com.github.damontecres.wholphin.data.CurrentUser) {
|
|
||||||
if (ConductarrPreference.fixtures) {
|
|
||||||
session.state = null
|
|
||||||
} else {
|
|
||||||
val saved = users.filterNot { it.id == current.user.id } + current.user
|
|
||||||
val people = saved.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
|
|
||||||
session.state = ShellState(people, current.user.name.orEmpty().lowercase()) { session.onAction(it) }.apply {
|
|
||||||
pickerVisible = false
|
|
||||||
pickerLaunch = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
session.serverId = current.server.id.toString()
|
|
||||||
session.fixtureMode = ConductarrPreference.fixtures
|
|
||||||
navigation.reloadHome()
|
|
||||||
setup.navigateTo(SetupDestination.AppContent(current))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun perform(action: suspend () -> Unit) {
|
|
||||||
if (busy) return
|
|
||||||
busy = true
|
|
||||||
message = null
|
|
||||||
try {
|
|
||||||
action()
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
if (exception is CancellationException) throw exception
|
|
||||||
message = if (exception is InvalidStatusException && exception.status == 401) "Invalid username or password. Try again."
|
|
||||||
else if (exception is InvalidStatusException && exception.status == 403) "This profile is not authorized. Choose another profile or sign in again."
|
|
||||||
else if (server == null && stage == Stage.Unconfigured) "Could not connect to the household server. Check the URL and try again."
|
|
||||||
else if (server == null) "Could not read the saved household server. Try again."
|
|
||||||
else "The household server didn’t answer. Try again or sign in with your server credentials."
|
|
||||||
if (stage != Stage.Login && stage != Stage.Unconfigured) stage = Stage.Unavailable
|
|
||||||
} finally {
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.update
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
/** Hilt entry point shared by app startup, Wolfskin UI and classic update controls. */
|
||||||
|
@Singleton
|
||||||
|
class AndroidUpdateService @Inject constructor(
|
||||||
|
@param:ApplicationContext context: Context,
|
||||||
|
@param:IoCoroutineScope scope: CoroutineScope,
|
||||||
|
dataStore: DataStore<AppPreferences>,
|
||||||
|
val platform: AndroidUpdatePlatform,
|
||||||
|
) {
|
||||||
|
private val preferences = object : UpdatePreferences {
|
||||||
|
override val autoCheck = dataStore.data.map { it.autoCheckForUpdates }.stateIn(scope, SharingStarted.Eagerly, true)
|
||||||
|
override suspend fun enabled() = dataStore.data.first().autoCheckForUpdates
|
||||||
|
override suspend fun url(): String {
|
||||||
|
val prefs = dataStore.updateData { current ->
|
||||||
|
val url = UpdateChannel.migrateUrl(context.packageName, current.updateUrl)
|
||||||
|
if (url == current.updateUrl) current else current.update { updateUrl = url }
|
||||||
|
}
|
||||||
|
return prefs.updateUrl
|
||||||
|
}
|
||||||
|
override suspend fun setAutoCheck(enabled: Boolean) {
|
||||||
|
dataStore.updateData { it.update { autoCheckForUpdates = enabled } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val controller = HouseholdUpdateController(scope, preferences, platform, UpdateTransport(),
|
||||||
|
VerifiedUpdateStore(File(context.noBackupFilesDir, "updates"), platform), Build.SUPPORTED_ABIS.toList(), Build.VERSION.SDK_INT)
|
||||||
|
suspend fun channelUrl(): String = preferences.url()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single process-scoped controller binding for every update entry point. */
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object UpdateModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUpdateController(service: AndroidUpdateService): UpdateController = service.controller
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
|
|
||||||
|
/** Trial entry point uses the same verified service, never the legacy download-and-install path. */
|
||||||
|
@Composable
|
||||||
|
fun ClassicUpdatePage(controller: UpdateController, modifier: Modifier = Modifier) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val installed by controller.installedVersion.collectAsState()
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
var notes by remember { mutableStateOf<String?>(null) }
|
||||||
|
LaunchedEffect(controller) { controller.check(true) }
|
||||||
|
DisposableEffect(lifecycle, controller) {
|
||||||
|
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) controller.onResume() }
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsRowGap)) {
|
||||||
|
Text("Installed: ${installed.versionName}")
|
||||||
|
when (val current = state) {
|
||||||
|
is UpdateUiState.Available -> {
|
||||||
|
Text("${current.versionName} · ${current.bytes} bytes · ${current.dateText}")
|
||||||
|
Button(onClick = controller::download) { Text("Download") }
|
||||||
|
if (current.notes.isNotBlank()) {
|
||||||
|
Button(onClick = { notes = current.notes }) { Text("Release notes") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is UpdateUiState.Downloading -> {
|
||||||
|
Text("${current.bytesDone} / ${current.bytesTotal ?: "—"} bytes")
|
||||||
|
Button(onClick = controller::cancel) { Text("Cancel download") }
|
||||||
|
}
|
||||||
|
is UpdateUiState.Ready, UpdateUiState.InstallCancelled, UpdateUiState.PermissionNeeded, UpdateUiState.NoInstaller -> {
|
||||||
|
Text(when (current) {
|
||||||
|
UpdateUiState.InstallCancelled -> "Installation cancelled; verified download retained"
|
||||||
|
UpdateUiState.PermissionNeeded -> "Allow updates from this app in Android settings"
|
||||||
|
UpdateUiState.NoInstaller -> "Android installer or permission settings unavailable"
|
||||||
|
else -> "Verified update ready"
|
||||||
|
})
|
||||||
|
Button(onClick = controller::install) { Text("Install update") }
|
||||||
|
}
|
||||||
|
is UpdateUiState.Failed -> {
|
||||||
|
Text(current.message)
|
||||||
|
Button(onClick = controller::retry) { Text("Retry") }
|
||||||
|
}
|
||||||
|
UpdateUiState.HandingOff -> Text("Confirm installation in Android")
|
||||||
|
else -> {
|
||||||
|
Text(when (current) {
|
||||||
|
UpdateUiState.UpToDate -> "Up to date"
|
||||||
|
UpdateUiState.NoReleasePublished -> "No release published"
|
||||||
|
UpdateUiState.Offline -> "No network"
|
||||||
|
UpdateUiState.Checking -> "Checking"
|
||||||
|
else -> "Check for an update"
|
||||||
|
})
|
||||||
|
Button(onClick = { controller.check(true) }) { Text("Check again") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notes?.let { body ->
|
||||||
|
ScrollableDialog(onDismissRequest = { notes = null }) {
|
||||||
|
item { Markdown(content = body) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
/** Package-scoped channel policy; the old production sandbox retains its original feed. */
|
||||||
|
object UpdateChannel {
|
||||||
|
const val PACKAGE = "org.justinvisser.wolfskin.tv.opt"
|
||||||
|
const val SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
|
||||||
|
const val LEGACY_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest"
|
||||||
|
const val LATEST_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest"
|
||||||
|
fun defaultUrl(packageId: String): String = if (packageId == PACKAGE) LATEST_URL else LEGACY_URL
|
||||||
|
fun migrateUrl(packageId: String, saved: String): String =
|
||||||
|
if (packageId == PACKAGE && (saved == LEGACY_URL || saved.isBlank())) LATEST_URL else saved
|
||||||
|
fun isNewer(candidateCode: Long, installedCode: Long): Boolean = candidateCode > installedCode
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Bundle
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Explicit, non-exported callback target accepts only the locally recorded install session. */
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class UpdateInstallReceiver : BroadcastReceiver() {
|
||||||
|
@Inject lateinit var service: AndroidUpdateService
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (!service.platform.ownsSession(intent)) return
|
||||||
|
val outcome = when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val confirmation = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||||
|
if (confirmation != null && service.platform.launchConfirmation(confirmation)) InstallOutcome.Pending else InstallOutcome.Unavailable
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Success
|
||||||
|
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
|
||||||
|
PackageInstaller.STATUS_FAILURE_STORAGE -> InstallOutcome.InsufficientStorage
|
||||||
|
else -> InstallOutcome.Failed
|
||||||
|
}
|
||||||
|
service.controller.handleInstallOutcome(outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System confirmation is launched for result so cancelling retains the already-verified APK. */
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class UpdateConfirmationActivity : ComponentActivity() {
|
||||||
|
@Inject lateinit var service: AndroidUpdateService
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (savedInstanceState != null) return
|
||||||
|
val confirmation = intent.getParcelableExtra<Intent>("confirmation")
|
||||||
|
if (confirmation == null || confirmation.resolveActivity(packageManager) == null) {
|
||||||
|
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { startActivityForResult(confirmation, 1) } catch (_: RuntimeException) {
|
||||||
|
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("System installer uses the activity result contract")
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
if (requestCode == 1 && resultCode == RESULT_CANCELED) service.controller.handleInstallOutcome(InstallOutcome.Cancelled)
|
||||||
|
service.controller.onResume()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/** Signed APK metadata published only after the final APKs have been inspected. */
|
||||||
|
@Serializable
|
||||||
|
data class UpdateManifest(
|
||||||
|
val schemaVersion: Int,
|
||||||
|
val packageId: String,
|
||||||
|
val versionName: String,
|
||||||
|
val versionCode: Long,
|
||||||
|
val publishedAt: String,
|
||||||
|
val sourceCommit: String,
|
||||||
|
val sourceTag: String,
|
||||||
|
val signerSha256: String,
|
||||||
|
val assets: List<UpdateAsset>,
|
||||||
|
) {
|
||||||
|
fun selectAsset(abis: List<String>, sdk: Int): UpdateAsset {
|
||||||
|
fun compatible(abi: String) = assets.firstOrNull { it.abi == abi && it.minSdk <= sdk }
|
||||||
|
if ("armeabi-v7a" in abis) compatible("armeabi-v7a")?.let { return it }
|
||||||
|
for (abi in abis) {
|
||||||
|
if (abi != "armeabi-v7a" && abi != "universal") compatible(abi)?.let { return it }
|
||||||
|
}
|
||||||
|
if (abis.any { it != "universal" && it in supportedAbis }) {
|
||||||
|
compatible("universal")?.let { return it }
|
||||||
|
}
|
||||||
|
throw UpdateException(UpdateFailure.UnsupportedAbi, "No compatible APK for this device")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val versionPattern = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
|
||||||
|
private val commitPattern = Regex("[0-9a-f]{40}")
|
||||||
|
private val hashPattern = Regex("[0-9a-f]{64}")
|
||||||
|
private val supportedAbis = setOf("armeabi-v7a", "arm64-v8a", "x86_64", "universal")
|
||||||
|
fun parse(text: String): UpdateManifest {
|
||||||
|
try {
|
||||||
|
val manifest = json.decodeFromString<UpdateManifest>(text)
|
||||||
|
require(manifest.schemaVersion == 1)
|
||||||
|
require(manifest.packageId.isNotBlank())
|
||||||
|
require(versionPattern.matches(manifest.versionName))
|
||||||
|
require(manifest.versionCode in 1..2100000000)
|
||||||
|
Instant.parse(manifest.publishedAt)
|
||||||
|
require(commitPattern.matches(manifest.sourceCommit))
|
||||||
|
require(manifest.sourceTag.isNotBlank())
|
||||||
|
require(isHash(manifest.signerSha256))
|
||||||
|
require(manifest.assets.isNotEmpty())
|
||||||
|
require(manifest.assets.map { it.abi }.distinct().size == manifest.assets.size)
|
||||||
|
require(manifest.assets.map { it.name }.distinct().size == manifest.assets.size)
|
||||||
|
manifest.assets.forEach {
|
||||||
|
require(it.abi in supportedAbis)
|
||||||
|
require(it.name == if (it.abi == "universal") "Wolfskin.apk" else "Wolfskin-${it.abi}.apk")
|
||||||
|
require(it.bytes > 0 && it.minSdk > 0 && isHash(it.sha256))
|
||||||
|
}
|
||||||
|
return manifest
|
||||||
|
} catch (error: Exception) {
|
||||||
|
throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update manifest", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun isHash(value: String) = hashPattern.matches(value)
|
||||||
|
fun encode(manifest: UpdateManifest): String = json.encodeToString(manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A standalone APK, not a split installation dependency. */
|
||||||
|
@Serializable
|
||||||
|
data class UpdateAsset(val abi: String, val name: String, val bytes: Long, val sha256: String, val minSdk: Int)
|
||||||
|
|
||||||
|
/** Selected immutable release metadata and its anonymous asset URL. */
|
||||||
|
data class UpdateCandidate(val manifest: UpdateManifest, val asset: UpdateAsset, val url: String, val notes: String)
|
||||||
|
|
||||||
|
/** Typed boundary error, never interpreted as an up-to-date result. */
|
||||||
|
class UpdateException(val kind: UpdateFailure, override val message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
// 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) }
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/** Dedicated unauthenticated client; redirects are individually validated before any request. */
|
||||||
|
class UpdateTransport(private val allowLocalTestHttp: Boolean = false) {
|
||||||
|
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).build()
|
||||||
|
private val activeCall = AtomicReference<Call?>()
|
||||||
|
|
||||||
|
fun cancel() { activeCall.get()?.cancel() }
|
||||||
|
|
||||||
|
private fun validateUrl(value: String): okhttp3.HttpUrl {
|
||||||
|
val url = value.toHttpUrlOrNull()
|
||||||
|
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update URL")
|
||||||
|
val fixture = BuildConfig.DEBUG && allowLocalTestHttp && url.host in listOf("localhost", "127.0.0.1", "::1")
|
||||||
|
if ((!url.isHttps && !fixture) || url.username.isNotEmpty() || url.password.isNotEmpty()) {
|
||||||
|
throw UpdateException(UpdateFailure.InvalidMetadata, "Update URLs must use credential-free HTTPS")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun request(value: String): Response {
|
||||||
|
var url = validateUrl(value)
|
||||||
|
repeat(6) {
|
||||||
|
val call = client.newCall(Request.Builder().url(url).get().build())
|
||||||
|
activeCall.set(call)
|
||||||
|
val response = suspendCancellableCoroutine<Response> { continuation ->
|
||||||
|
continuation.invokeOnCancellation { call.cancel() }
|
||||||
|
try {
|
||||||
|
continuation.resume(call.execute()) { _, response, _ -> response.close() }
|
||||||
|
} catch (error: Exception) { continuation.resumeWithException(error) }
|
||||||
|
}
|
||||||
|
if (response.code !in listOf(301, 302, 303, 307, 308)) return response
|
||||||
|
response.use {
|
||||||
|
val location = it.header("Location")
|
||||||
|
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Redirect has no location")
|
||||||
|
url = validateUrl(url.resolve(location)?.toString()
|
||||||
|
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid redirect"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw UpdateException(UpdateFailure.HttpRefused, "Too many update redirects")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readJson(url: String): kotlinx.serialization.json.JsonElement = request(url).use {
|
||||||
|
requireSuccess(it)
|
||||||
|
val source = it.body.source()
|
||||||
|
if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) {
|
||||||
|
throw UpdateException(UpdateFailure.InvalidMetadata, "Update metadata is too large")
|
||||||
|
}
|
||||||
|
try { Json.parseToJsonElement(source.readUtf8()) } catch (error: java.io.IOException) { throw error } catch (error: Exception) {
|
||||||
|
throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release JSON", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installed-release notes share the same anonymous redirect policy as checks and assets. */
|
||||||
|
suspend fun releasesForLatest(url: String, limit: Int = 50): JsonArray {
|
||||||
|
val latest = validateUrl(url)
|
||||||
|
val list = latest.newBuilder()
|
||||||
|
var last = latest.pathSegments.lastIndex
|
||||||
|
while (last > 0 && latest.pathSegments[last].isEmpty()) {
|
||||||
|
list.removePathSegment(last)
|
||||||
|
last--
|
||||||
|
}
|
||||||
|
return readJson(list.removePathSegment(last).setQueryParameter("limit", limit.toString()).build().toString()).jsonArray
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A latest 404 is empty only when the release-list endpoint successfully returns an empty array. */
|
||||||
|
suspend fun latest(url: String, abis: List<String>, sdk: Int): UpdateCandidate? {
|
||||||
|
try {
|
||||||
|
val release = request(url).use {
|
||||||
|
if (it.code == 404) {
|
||||||
|
if (releasesForLatest(url, 1).isEmpty()) return null
|
||||||
|
}
|
||||||
|
requireSuccess(it)
|
||||||
|
val source = it.body.source()
|
||||||
|
if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) {
|
||||||
|
throw UpdateException(UpdateFailure.InvalidMetadata, "Release metadata is too large")
|
||||||
|
}
|
||||||
|
Json.parseToJsonElement(source.readUtf8()).jsonObject
|
||||||
|
}
|
||||||
|
return candidate(release, abis, sdk)
|
||||||
|
} catch (error: UpdateException) { throw error } catch (error: java.io.IOException) { throw error }
|
||||||
|
catch (error: kotlinx.coroutines.CancellationException) { throw error }
|
||||||
|
catch (error: Exception) { throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release metadata", error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun candidate(release: JsonObject, abis: List<String>, sdk: Int): UpdateCandidate {
|
||||||
|
val assets = release.getValue("assets").jsonArray.map { it.jsonObject }
|
||||||
|
val manifestAsset = assets.single { it["name"]?.jsonPrimitive?.content == "wolfskin-update.json" }
|
||||||
|
val manifest = UpdateManifest.parse(readJson(manifestAsset.getValue("browser_download_url").jsonPrimitive.content).toString())
|
||||||
|
val selected = manifest.selectAsset(abis, sdk)
|
||||||
|
val asset = assets.single { it["name"]?.jsonPrimitive?.content == selected.name }
|
||||||
|
return UpdateCandidate(manifest, selected, validateUrl(asset.getValue("browser_download_url").jsonPrimitive.content).toString(),
|
||||||
|
release["body"]?.jsonPrimitive?.contentOrNull.orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh transfer always truncates partials; no unvalidated HTTP range concatenation. */
|
||||||
|
suspend fun download(candidate: UpdateCandidate, file: File, progress: (Long, Long?) -> Unit) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
request(candidate.url).use { response ->
|
||||||
|
requireSuccess(response)
|
||||||
|
val length = response.body.contentLength().takeIf { it >= 0 }
|
||||||
|
if (length != null && length != candidate.asset.bytes) {
|
||||||
|
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK length differs from manifest")
|
||||||
|
}
|
||||||
|
response.body.byteStream().use { input ->
|
||||||
|
file.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
var done = 0L
|
||||||
|
while (true) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
val count = input.read(buffer)
|
||||||
|
if (count == -1) break
|
||||||
|
done += count
|
||||||
|
if (done > candidate.asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK exceeds manifest length")
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
progress(done, length)
|
||||||
|
}
|
||||||
|
output.fd.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireSuccess(response: Response) {
|
||||||
|
if (!response.isSuccessful) throw UpdateException(UpdateFailure.HttpRefused, "Update server refused request (HTTP ${response.code})")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object { private const val MAX_METADATA_BYTES = 1024L * 1024 }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,7 +74,7 @@ Row reveals are grouped rather than the browser prototype's per-card staging: th
|
||||||
|
|
||||||
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. 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 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 loading keeps nav focus and Back still pops the title/person route.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
|
||||||
306
docker/README.md
306
docker/README.md
|
|
@ -11,7 +11,11 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds
|
Offline JVM tests use instrumented Android 9, 11 and 14 SDK jars. Cache preparation
|
||||||
|
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
|
||||||
|
|
@ -27,6 +31,159 @@ 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
|
||||||
|
|
@ -89,20 +246,22 @@ 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. It changes only signing (the Android debug key),
|
dependency baseline profiles. Its package (`org.justinvisser.wolfskin.tv.opt`),
|
||||||
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
|
launcher label (`Wolfskin (Opt)`) and persistent signer remain unchanged. It is
|
||||||
The launcher icon/banner and release-compatible version name are unchanged:
|
the household release assembly, with plain versionName `1.1.1` and an explicit
|
||||||
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
|
monotonic versionCode; source commit identity is recorded separately.
|
||||||
`make apk` remains the clean, release-key-signed production build. Debug and
|
`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and
|
||||||
optimized containers share the persistent debug key described below; release
|
release signer. It must never publish to the household channel.
|
||||||
signing is unchanged.
|
|
||||||
|
|
||||||
Build all four default-flavor outputs in one invocation through the container
|
For build-only diagnostics, build all four outputs through the container wrapper
|
||||||
wrapper, without a release keystore. On a host whose Gradle cache already holds
|
with a deliberately non-release code. **Direct optimized builds must never be
|
||||||
the pinned native extensions and their metadata:
|
installed on the household box or published.** Use `make tv-release` for every
|
||||||
|
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
|
||||||
|
|
@ -115,7 +274,8 @@ 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 before installing.
|
playback-complete comparison. Inspect native libraries for diagnostic evidence;
|
||||||
|
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
|
||||||
|
|
@ -185,6 +345,15 @@ 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
|
||||||
|
|
@ -241,33 +410,6 @@ household labels, requester capabilities and library audiences after sign-in.
|
||||||
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
||||||
only when the Advanced debug fixture switch is enabled; release excludes it.
|
only when the Advanced debug fixture switch is enabled; release excludes it.
|
||||||
|
|
||||||
### Household setup and sign-in recovery
|
|
||||||
|
|
||||||
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
|
|
||||||
destinations before their stock server/user selectors can render. Cold start,
|
|
||||||
restart with automatic sign-in disabled, the old drawer's user action, legacy
|
|
||||||
sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's
|
|
||||||
own picker. Entering it does not call `SwitchUserViewModel.init` or clear the
|
|
||||||
remembered server/user preference.
|
|
||||||
|
|
||||||
Setup reuses the supplied/current/recent household server, recovering the most
|
|
||||||
recently used Room server if the legacy picker erased the preference. Saved people
|
|
||||||
render without a network request. Only an install with no saved server gets the
|
|
||||||
one-time Jellyfin URL field; it uses existing Jellyfin discovery and Room
|
|
||||||
persistence, not a new compiled endpoint. Conductarr remains on its existing
|
|
||||||
configured URL and enriches the signed-in shell as before.
|
|
||||||
|
|
||||||
Selecting a protected profile still requires its PIN or server credentials.
|
|
||||||
Expired credentials during saved-session revalidation or foreground refresh
|
|
||||||
return to the picker without allowing a late failure to replace another person's
|
|
||||||
session. A failed selection offers Wolfskin **Try again** and **Sign in** actions;
|
|
||||||
hidden/unsaved profiles can use username/password sign-in through the setup
|
|
||||||
picker's Settings gear. An outage never replaces the saved household endpoint.
|
|
||||||
|
|
||||||
Turning **Wolfskin screens** off retains the unchanged upstream
|
|
||||||
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
|
|
||||||
fixtures still seed the existing fixture picker after authentication.
|
|
||||||
|
|
||||||
Person selection immediately clears the old Conductarr identity and Jellyfin
|
Person selection immediately clears the old Conductarr identity and Jellyfin
|
||||||
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
||||||
the application's IO scope, after the replacement (or signed-out) identity is
|
the application's IO scope, after the replacement (or signed-out) identity is
|
||||||
|
|
@ -670,24 +812,18 @@ 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 series Backdrop.
|
- Episode images choose episode Primary, then Thumb, then the already-read
|
||||||
Title episode cards now use 448×280 px, preserving the taller approved crop.
|
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
|
||||||
Focused summaries ellipsise to one line, the show-name overlay is absent, and
|
Episode context uses the episode overview, with two summary lines on the
|
||||||
`S1 E3 · duration` plus the watched check share one line.
|
focused card. Held-series headers measure their copy/actions and leave 40 px
|
||||||
Ready title headers measure their copy/actions and leave 40 px before the
|
before seasons instead of reserving a fixed empty 900 px header.
|
||||||
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
|
||||||
448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
400×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
|
||||||
|
|
@ -739,28 +875,6 @@ 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
|
||||||
|
|
@ -785,10 +899,7 @@ 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 and long-sheet visibility. `TitleRound2Test` covers
|
entry, remembered columns, long-sheet visibility and full media-table geometry.
|
||||||
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.
|
||||||
|
|
@ -1007,14 +1118,12 @@ 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` runs `OwnedSourceAuditTest` through the design module's JVM suite.
|
`make test` keeps the native capture checks for rendered font sizes and visible
|
||||||
It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and
|
surface behavior, plus generated-token drift checks. Styling uses generated
|
||||||
`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color
|
tokens rather than raw dimensions. The source-regex wording/style audit was
|
||||||
constructors outside generated tokens, token font sizes below 12 sp, raw
|
removed: source spelling is not a consumer-visible behavior contract.
|
||||||
household library labels, and page-counter/navigation wording. Its negative
|
`LibraryProgressiveSurfaceTest` captures the usable first chunk while the final
|
||||||
cases plant each violation independently. Existing native capture checks also
|
read is still suspended.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -1061,21 +1170,25 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
The wrapper creates a missing key once using the build image's `keytool`: RSA
|
For ordinary debug builds only, the wrapper creates a missing key once using
|
||||||
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
|
the build image's `keytool`: RSA 2048, 10,000-day validity, alias
|
||||||
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
|
`androiddebugkey`, store/key passwords `android`, subject `CN=Android Debug`.
|
||||||
file mode `0600`. Existing keys are reused, never regenerated; generation errors
|
Household and optimized modes require the existing key and refuse generation.
|
||||||
abort the build rather than falling back to an ephemeral container key.
|
The directory is mode `0700`, the file mode `0600`; existing keys are reused.
|
||||||
|
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; this key is for local debug/optimized builds, not release signing.
|
are public; the private key is nevertheless the permanent household signing
|
||||||
|
authority. It is distinct from the old production package's release signer.
|
||||||
|
|
||||||
### Preserve household TV data
|
### Preserve household TV data
|
||||||
|
|
||||||
Install and update the household TV app only in place with `adb install -r`,
|
Normal household updates use the in-app verified download and explicit Android
|
||||||
using the same package and persistent debug signer. Its certificate SHA-256 is:
|
confirmation, preserving the same package and persistent signer. A separately
|
||||||
|
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
|
||||||
|
|
@ -1093,9 +1206,10 @@ 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.
|
||||||
|
|
||||||
For an in-place update, compare `dumpsys package <pkg>` before and after:
|
An operator can compare `dumpsys package <pkg>` before and after an in-place
|
||||||
`firstInstallTime` must remain unchanged. The guarded installer that checks
|
update: `firstInstallTime` must remain unchanged. The in-app service checks
|
||||||
the signer and this timestamp lives in the operator's tooling, not this repo.
|
package, current signer, checksum and higher code; real-device timestamp and
|
||||||
|
retained-login proof remain a separate operator walk, not a JVM-test claim.
|
||||||
|
|
||||||
|
|
||||||
## Release credentials
|
## Release credentials
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ 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
|
||||||
|
|
@ -66,5 +70,15 @@ 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")
|
||||||
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
if [[ $mode == household ]]; then
|
||||||
|
[[ -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
|
||||||
|
|
|
||||||
371
docker/test_tv_publish.py
Normal file
371
docker/test_tv_publish.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""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()
|
||||||
159
docker/test_tv_release.py
Normal file
159
docker/test_tv_release.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""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()
|
||||||
3
docker/tv-version-codes
Normal file
3
docker/tv-version-codes
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Household org.justinvisser.wolfskin.tv.opt codes already used.
|
||||||
|
# Append successful tv-release codes; commit this ledger before another release.
|
||||||
|
65 installed-opt
|
||||||
413
docker/tv_publish.py
Executable file
413
docker/tv_publish.py
Executable file
|
|
@ -0,0 +1,413 @@
|
||||||
|
#!/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()
|
||||||
254
docker/tv_release.py
Executable file
254
docker/tv_release.py
Executable file
|
|
@ -0,0 +1,254 @@
|
||||||
|
#!/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,18 +19,23 @@ 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) {
|
||||||
from(robolectricSdk)
|
val versions = listOf(
|
||||||
|
"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", "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", "update-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)
|
||||||
|
|
@ -50,9 +55,6 @@ 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,9 +121,10 @@ 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", "generate_tokens.py"]
|
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-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()))
|
||||||
|
|
|
||||||
47
wolfskin-design/src/main/assets/update-font-license.txt
Normal file
47
wolfskin-design/src/main/assets/update-font-license.txt
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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
|
||||||
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
|
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)) }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,24 @@ 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) {
|
fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) {
|
||||||
Row(modifier) {
|
Row(modifier) {
|
||||||
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
|
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle)
|
||||||
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
|
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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,
|
||||||
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
|
backgroundColor: Color = personColor(person.key)) {
|
||||||
|
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,15 +40,16 @@ 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>): List<NavBox> {
|
fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox> {
|
||||||
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
|
val updates = WolfskinTokens.Update
|
||||||
|
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" -> S.navHomeWidth
|
"home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth
|
||||||
"discover" -> S.navDiscoverWidth
|
"discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth
|
||||||
"movies" -> S.navMoviesWidth
|
"movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth
|
||||||
"shows" -> S.navShowsWidth
|
"shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth
|
||||||
else -> S.navAnimeWidth
|
else -> if (isUpdatesLayout) updates.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)
|
||||||
|
|
@ -57,7 +58,8 @@ fun navBoxes(tabs: List<String>): 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 boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
val hasSelectedTab = state.route != "settings" && state.route != "updates"
|
||||||
|
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
|
||||||
|
|
@ -79,14 +81,15 @@ 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)
|
||||||
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||||
}
|
}
|
||||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf",
|
||||||
|
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 (itemIndex == state.currentTab) C.fg else C.muted,
|
if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted,
|
||||||
animationSpec = labelFade,
|
animationSpec = labelFade,
|
||||||
label = "nav-label-$tab",
|
label = "nav-label-$tab",
|
||||||
)
|
)
|
||||||
|
|
@ -95,8 +98,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 = WolfskinTokens.Type.nav, color = labelColor)
|
Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor)
|
||||||
if (itemIndex == state.currentTab && !focused) {
|
if (hasSelectedTab && 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,10 +114,11 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val avatar = boxes.last()
|
val avatar = boxes.last()
|
||||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.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,13 +9,12 @@ 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
|
||||||
|
|
@ -36,50 +35,53 @@ 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, val wolfskin: Boolean = false)
|
data class SettingsGroup(val key: String, val title: String, val description: String)
|
||||||
|
|
||||||
val SETTINGS_GROUPS = listOf(
|
val SETTINGS_GROUPS = listOf(
|
||||||
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
|
SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
|
||||||
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
|
SettingsGroup("updates", "Updates", "Check and install"),
|
||||||
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
|
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
|
||||||
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
|
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
|
||||||
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
|
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
|
||||||
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
|
SettingsGroup("profile", "User profile settings", "Protection · Home · Language"),
|
||||||
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
|
SettingsGroup("about", "About", "Installed version · Wolfskin about"),
|
||||||
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"),
|
||||||
|
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) {
|
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
||||||
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 y = L.row_lhead + L.row_srow * state.settingsRow
|
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
||||||
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
|
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
||||||
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
|
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
LazyColumn(modifier.fillMaxSize(), state = list,
|
Box(modifier.fillMaxSize()) {
|
||||||
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
|
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
||||||
item(key = "settings-header") {
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
||||||
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
|
Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(state.person))
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
|
||||||
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop)
|
||||||
Box(Modifier.height(L.row_srow)) {
|
.height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)),
|
||||||
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
|
||||||
if (group.key == "requests") requestCount else null,
|
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
Box(Modifier.height(L.row_srow)) {
|
||||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,15 +96,14 @@ 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(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
Glyph(if (group.key == "updates") "updates" else "settings-list", 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(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1)
|
||||||
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)
|
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
if (group.wolfskin) SettingsPill("Wolfskin", focused)
|
SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
|
||||||
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
|
Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||||
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +123,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 = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
Text(label, color = foreground, style = T.caption.copy(fontWeight = T.label.fontWeight))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,9 +260,13 @@ class ShellState(
|
||||||
}
|
}
|
||||||
} else if (route == "settings") {
|
} else if (route == "settings") {
|
||||||
val group = SETTINGS_GROUPS[settingsRow].key
|
val group = SETTINGS_GROUPS[settingsRow].key
|
||||||
if (group == "requests") openRequests() else {
|
when (group) {
|
||||||
handOff = group
|
"requests" -> openRequests()
|
||||||
onAction(ShellAction.HandOff(group))
|
"updates" -> onAction(ShellAction.Destination("updates"))
|
||||||
|
else -> {
|
||||||
|
handOff = group
|
||||||
|
onAction(ShellAction.HandOff(group))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else contentFocus?.key(ShellKey.Ok)
|
else contentFocus?.key(ShellKey.Ok)
|
||||||
|
|
@ -305,6 +309,7 @@ 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,6 +56,7 @@ 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) },
|
||||||
) {
|
) {
|
||||||
|
|
@ -91,7 +92,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) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) 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,7 +32,6 @@ 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
|
||||||
|
|
@ -111,7 +110,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).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
.size(L.heroSheetWidth, L.heroSheetHeight).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,9 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -39,11 +35,6 @@ 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
|
||||||
|
|
@ -65,10 +56,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(factsItem.ratings)
|
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.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(readinessHeight).clipToBounds().testTag("title-readiness-slot")) {
|
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).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) {
|
||||||
|
|
@ -83,7 +74,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?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
|
state.data.playTarget?.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))) {
|
||||||
|
|
@ -92,6 +83,7 @@ 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)) {
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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 { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
enum class TitleSheet { 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)
|
||||||
TitleSheet.Menu, null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,7 @@ 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 && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
if (!shell.pickerVisible && state.sheet != null) 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,11 +91,9 @@ 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 compactHeader = state.data.status == TitleReadStatus.Ready
|
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
||||||
val item = state.data.item
|
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
||||||
val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop))
|
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
@ -120,18 +117,7 @@ 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 (compensateHeader) return@LaunchedEffect
|
|
||||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||||
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]
|
||||||
|
|
@ -168,7 +154,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 (compactHeader) Modifier.onSizeChanged { size ->
|
TitleHeader(state, active, if (compactSeries) 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).testTag("title-cast")) {
|
Box(Modifier.height(D.castHeight)) {
|
||||||
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) D.episodeCardWidth else WolfskinTokens.Card.posterW
|
val width = if (episodes) WolfskinTokens.Card.tileW 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.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() else WolfskinTokens.Card.posterSlots
|
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
||||||
val pitch = with(LocalDensity.current) { (if (episodes) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() }
|
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch 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(if (episodes) WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(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" }) { index ->
|
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||||
Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(width, height).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).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier)
|
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
||||||
.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,16 +117,17 @@ 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() },
|
||||||
D.episodeCardWidth, D.episodeCardHeight, watched = item.watched,
|
WolfskinTokens.Card.tileW, 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.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, 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)
|
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0)
|
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
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()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) {
|
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
|
||||||
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,6 @@ 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)
|
||||||
|
|
@ -72,7 +65,6 @@ 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. */
|
||||||
|
|
@ -148,7 +140,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 -> closeSheet()
|
sheet != null -> sheet = null
|
||||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||||
else -> onAction(TitleAction.Pop)
|
else -> onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
|
|
@ -190,12 +182,13 @@ 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 }
|
||||||
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
||||||
"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", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
"request", "other", "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"
|
||||||
|
|
@ -212,10 +205,9 @@ class TitleState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sheetKey(key: ShellKey) {
|
private fun sheetKey(key: ShellKey) {
|
||||||
if (key == ShellKey.Back) { closeSheet(); return }
|
if (key == ShellKey.Back) { sheet = null; 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
|
||||||
|
|
@ -225,16 +217,6 @@ 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
|
||||||
|
|
@ -252,19 +234,11 @@ class TitleState(
|
||||||
onAction(TitleAction.Hide(data.item, false))
|
onAction(TitleAction.Hide(data.item, false))
|
||||||
} else onAction(TitleAction.Pop)
|
} else onAction(TitleAction.Pop)
|
||||||
}
|
}
|
||||||
else -> closeSheet()
|
else -> sheet = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -300,12 +274,14 @@ 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 || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
|
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
|
||||||
|
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("menu", "Menu", "more"))
|
add(TitleOption("other", "For someone else", "users", icon = false))
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
// 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,6 +221,9 @@ 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
|
||||||
|
|
@ -262,7 +265,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 = 448
|
const val episodeRequestWidth = 400
|
||||||
const val episodeRequestHeight = 280
|
const val episodeRequestHeight = 280
|
||||||
const val castRequestWidth = 200
|
const val castRequestWidth = 200
|
||||||
const val castRequestHeight = 200
|
const val castRequestHeight = 200
|
||||||
|
|
@ -451,11 +454,8 @@ 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,4 +703,48 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<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>
|
||||||
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans.ttf
Normal file
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans.ttf
Normal file
Binary file not shown.
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf
Normal file
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf
Normal file
Binary file not shown.
|
|
@ -44,19 +44,20 @@ 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 reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() {
|
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
|
||||||
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 { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } }
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||||
|
rule.waitForIdle()
|
||||||
val before = bounds()
|
val before = bounds()
|
||||||
assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
for (stage in listOf("Ready", "Downloading", null)) {
|
||||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) }
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
|
||||||
assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
rule.mainClock.advanceTimeBy(3400)
|
||||||
bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) }
|
rule.waitForIdle()
|
||||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) }
|
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
|
||||||
assertEquals(before, bounds())
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
// 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 = 4 }
|
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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,6 +46,22 @@ 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)
|
||||||
|
|
@ -54,10 +70,10 @@ class ShellKeysTest {
|
||||||
assertEquals(state.tabs.size, state.navIndex)
|
assertEquals(state.tabs.size, state.navIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun playbackHandOffInElevenPresses() {
|
@Test fun playbackHandOffInTwelvePresses() {
|
||||||
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
|
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12)
|
||||||
assertEquals("playback", state.handOff)
|
assertEquals("playback", state.handOff)
|
||||||
assertEquals(2, state.settingsRow)
|
assertEquals(3, state.settingsRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.design
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.MotionDurationScale
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onRoot
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
|
||||||
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
|
|
||||||
|
|
||||||
/** Readiness changes move real title content continuously, without reserving an empty band. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
class TitleReadinessMotionTest {
|
|
||||||
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
|
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
|
||||||
private lateinit var state: TitleState
|
|
||||||
|
|
||||||
private fun render() {
|
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
|
||||||
val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview")
|
|
||||||
val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview")
|
|
||||||
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.mainClock.advanceTimeBy(500)
|
|
||||||
rule.waitForIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun positions(): List<Float> = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag ->
|
|
||||||
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
|
||||||
if (index == 0) bounds.height else bounds.top
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun frames(stage: String?, name: String, count: Int = 22): List<List<Float>> {
|
|
||||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() }
|
|
||||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
|
||||||
val values = List(count) { index ->
|
|
||||||
rule.waitForIdle()
|
|
||||||
positions().also {
|
|
||||||
rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath)
|
|
||||||
rule.mainClock.advanceTimeBy(16)
|
|
||||||
rule.waitForIdle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n"))
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() {
|
|
||||||
render()
|
|
||||||
val collapsed = positions()
|
|
||||||
assertEquals(0f, collapsed[0], 0.1f)
|
|
||||||
rule.mainClock.autoAdvance = false
|
|
||||||
val opening = frames("Ready", "expand")
|
|
||||||
val expanded = opening.last()
|
|
||||||
assertEquals(56f, expanded[0], 0.1f)
|
|
||||||
expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) }
|
|
||||||
val closing = frames(null, "collapse")
|
|
||||||
assertEquals(collapsed, closing.last())
|
|
||||||
for ((samples, direction) in listOf(opening to 1f, closing to -1f)) {
|
|
||||||
samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) ->
|
|
||||||
assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f)
|
|
||||||
assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f)
|
|
||||||
} }
|
|
||||||
samples.forEach { assertTrue(it[0] in 0f..56f) }
|
|
||||||
}
|
|
||||||
assertFalse(state.options.any { it.id == "seasons" })
|
|
||||||
rule.mainClock.autoAdvance = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() {
|
|
||||||
render()
|
|
||||||
rule.runOnIdle { state.focus("episodes", 0) }
|
|
||||||
rule.mainClock.advanceTimeBy(500)
|
|
||||||
rule.waitForIdle()
|
|
||||||
val pinned = positions().last()
|
|
||||||
rule.mainClock.autoAdvance = false
|
|
||||||
for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) {
|
|
||||||
samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) }
|
|
||||||
}
|
|
||||||
assertEquals("episodes", state.row)
|
|
||||||
rule.mainClock.autoAdvance = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,7 @@ class WolfskinImagesTest {
|
||||||
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
|
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
|
||||||
Surface("Home tile", 400, 225, "w780"),
|
Surface("Home tile", 400, 225, "w780"),
|
||||||
Surface("Home wide", 544, 306, "w780"),
|
Surface("Home wide", 544, 306, "w780"),
|
||||||
Surface("episode still", 448, 280, "w780"),
|
Surface("episode still", 400, 280, "w780"),
|
||||||
Surface("cast portrait", 200, 200, "w342"),
|
Surface("cast portrait", 200, 200, "w342"),
|
||||||
Surface("person portrait", 240, 240, "w342"),
|
Surface("person portrait", 240, 240, "w342"),
|
||||||
Surface("hero poster", 420, 630, "w342", true),
|
Surface("hero poster", 420, 630, "w342", true),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
{
|
{
|
||||||
"title": {
|
"title": {
|
||||||
"episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"},
|
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
|
||||||
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"},
|
|
||||||
"actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"},
|
|
||||||
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
||||||
"posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"},
|
|
||||||
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
|
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
|
||||||
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
|
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
|
||||||
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
|
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,9 @@
|
||||||
--layout-castCircle: 200.0px;
|
--layout-castCircle: 200.0px;
|
||||||
--layout-personPortrait: 240.0px;
|
--layout-personPortrait: 240.0px;
|
||||||
--layout-row_chipsGrid: 136.0px;
|
--layout-row_chipsGrid: 136.0px;
|
||||||
|
--motion-updateScan: 1800ms;
|
||||||
|
--motion-updateSheen: 1600ms;
|
||||||
|
--motion-linear: cubic-bezier(0, 0, 1, 1);
|
||||||
--motion-skeletonCycle: 1400ms;
|
--motion-skeletonCycle: 1400ms;
|
||||||
--motion-focusMove: 180ms;
|
--motion-focusMove: 180ms;
|
||||||
--motion-captionDelay: 60ms;
|
--motion-captionDelay: 60ms;
|
||||||
|
|
@ -290,11 +293,8 @@
|
||||||
--lift-heroY: 30.0px;
|
--lift-heroY: 30.0px;
|
||||||
--lift-sheetBlur: 80.0px;
|
--lift-sheetBlur: 80.0px;
|
||||||
--lift-sheetX: -30.0px;
|
--lift-sheetX: -30.0px;
|
||||||
--title-episodeCardWidth: 448.0px;
|
|
||||||
--title-episodeCardHeight: 280.0px;
|
--title-episodeCardHeight: 280.0px;
|
||||||
--title-actionMenuWidth: 520.0px;
|
|
||||||
--title-seriesHeaderGap: 40.0px;
|
--title-seriesHeaderGap: 40.0px;
|
||||||
--title-posterCastGap: 20.0px;
|
|
||||||
--title-lineGap: 22.0px;
|
--title-lineGap: 22.0px;
|
||||||
--title-lineLift: -4.0px;
|
--title-lineLift: -4.0px;
|
||||||
--title-overviewGap: 34.0px;
|
--title-overviewGap: 34.0px;
|
||||||
|
|
@ -519,4 +519,42 @@
|
||||||
--discover-stageGap: 10.0px;
|
--discover-stageGap: 10.0px;
|
||||||
--discover-ambientInset: 80.0px;
|
--discover-ambientInset: 80.0px;
|
||||||
--discover-ambientBlur: 60.0px;
|
--discover-ambientBlur: 60.0px;
|
||||||
|
--update-avatarJustin: #7ab8ff;
|
||||||
|
--update-wordmarkWidth: 163.59375px;
|
||||||
|
--update-navHomeWidth: 149.328125px;
|
||||||
|
--update-navDiscoverWidth: 198.140625px;
|
||||||
|
--update-navMoviesWidth: 170.515625px;
|
||||||
|
--update-navShowsWidth: 161.15625px;
|
||||||
|
--update-navAnimeWidth: 158.46875px;
|
||||||
|
--update-notesListTop: 28.0px;
|
||||||
|
--update-notesListBottom: 28.0px;
|
||||||
|
--update-notesMarkerOffset: 11.0px;
|
||||||
|
--update-notesMarkerWidth: 29.0px;
|
||||||
|
--update-heading: 36.0px;
|
||||||
|
--update-headerTop: 150.0px;
|
||||||
|
--update-headerHeight: 84.0px;
|
||||||
|
--update-rowsTop: 278.0px;
|
||||||
|
--update-rowsViewport: 724.0px;
|
||||||
|
--update-contentTop: 298.0px;
|
||||||
|
--update-width: 1300.0px;
|
||||||
|
--update-padding: 40.0px;
|
||||||
|
--update-border: 2.0px;
|
||||||
|
--update-radius: 22.0px;
|
||||||
|
--update-titleBottom: 16.0px;
|
||||||
|
--update-paragraphBottom: 14.0px;
|
||||||
|
--update-actionsTop: 36.0px;
|
||||||
|
--update-actionGap: 24.0px;
|
||||||
|
--update-progressTop: 30.0px;
|
||||||
|
--update-progressBottom: 22.0px;
|
||||||
|
--update-progressHeight: 12.0px;
|
||||||
|
--update-progressRadius: 8.0px;
|
||||||
|
--update-autoTop: 862.0px;
|
||||||
|
--update-toggleWidth: 80.0px;
|
||||||
|
--update-toggleHeight: 42.0px;
|
||||||
|
--update-toggleThumb: 30.0px;
|
||||||
|
--update-toggleInset: 6.0px;
|
||||||
|
--update-notesHeight: 512.0px;
|
||||||
|
--update-notesGap: 18.0px;
|
||||||
|
--update-notesScrollStep: 80.0px;
|
||||||
|
--update-backTop: 184.0px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1400,6 +1400,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
|
"updateScan": { "$type": "duration", "$value": "1800ms", "source": "approved Updates progress busy scan" },
|
||||||
|
"updateSheen": { "$type": "duration", "$value": "1600ms", "source": "approved Updates download highlight" },
|
||||||
|
"linear": { "$type": "cubicBezier", "$value": [0, 0, 1, 1] },
|
||||||
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
|
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
|
||||||
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
|
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
|
||||||
"focusMove": { "$type": "duration", "$value": "180ms" },
|
"focusMove": { "$type": "duration", "$value": "180ms" },
|
||||||
|
|
@ -1543,9 +1546,9 @@
|
||||||
"source": "feedback-2026-11-06: largest upright hero poster slot"
|
"source": "feedback-2026-11-06: largest upright hero poster slot"
|
||||||
},
|
},
|
||||||
"episodeRequest": {
|
"episodeRequest": {
|
||||||
"px": [448, 280],
|
"px": [400, 280],
|
||||||
"decode": "ARGB_8888",
|
"decode": "ARGB_8888",
|
||||||
"source": "tv-round2: wider episode cards at their preserved height"
|
"source": "feedback-2026-11-06: taller episode cards"
|
||||||
},
|
},
|
||||||
"castRequest": {
|
"castRequest": {
|
||||||
"px": [
|
"px": [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||||
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
|
||||||
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
||||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
||||||
0d69fccfbfada103268bfc38186205cf5f94daeb345a00a6aec585609e7ddb1e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
8efafb988e507e367630212db4af4f40b529be55d0f90577a7be6dd98a86b4d4 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
|
||||||
a68f336e8b4373e97a7acaeb02383b4a40dc9f7cc6f6e5413341f14fb2d308eb tokens.css
|
5f3b6d52c7ecfe49db59d2bd74acc1781ff7196db62bae3827d8f52b50d65aea tokens.css
|
||||||
b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json
|
3ed41f38aac01a608fb3e61c836180cf1ad76fe7cd1aac8b126f0a4adc0b2db1 tokens.json
|
||||||
|
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json
|
||||||
|
|
|
||||||
167
wolfskin-design/update-tokens.json
Normal file
167
wolfskin-design/update-tokens.json
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
{
|
||||||
|
"update": {
|
||||||
|
"avatarJustin": {"$type": "color", "$value": "#7ab8ff"},
|
||||||
|
"wordmarkWidth": {"$type": "dimension", "px": 163.59375, "dp": 81.796875},
|
||||||
|
"navHomeWidth": {"$type": "dimension", "px": 149.328125, "dp": 74.6640625},
|
||||||
|
"navDiscoverWidth": {"$type": "dimension", "px": 198.140625, "dp": 99.0703125},
|
||||||
|
"navMoviesWidth": {"$type": "dimension", "px": 170.515625, "dp": 85.2578125},
|
||||||
|
"navShowsWidth": {"$type": "dimension", "px": 161.15625, "dp": 80.578125},
|
||||||
|
"navAnimeWidth": {"$type": "dimension", "px": 158.46875, "dp": 79.234375},
|
||||||
|
"notesListTop": {"$type": "dimension", "px": 28, "dp": 14},
|
||||||
|
"notesListBottom": {"$type": "dimension", "px": 28, "dp": 14},
|
||||||
|
"notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5},
|
||||||
|
"notesMarkerWidth": {"$type": "dimension", "px": 29, "dp": 14.5},
|
||||||
|
"heading": {
|
||||||
|
"$type": "typography",
|
||||||
|
"$value": {"fontWeight": 800},
|
||||||
|
"px": 36,
|
||||||
|
"sp": 18,
|
||||||
|
"weight": 800,
|
||||||
|
"lineHeight": 1.12,
|
||||||
|
"letterSpacingEm": -0.02
|
||||||
|
},
|
||||||
|
"settingsVisibleRows": {"$type": "number", "$value": 6},
|
||||||
|
"headerTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 150,
|
||||||
|
"dp": 75.0
|
||||||
|
},
|
||||||
|
"headerHeight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 84,
|
||||||
|
"dp": 42.0
|
||||||
|
},
|
||||||
|
"rowsTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 278,
|
||||||
|
"dp": 139.0
|
||||||
|
},
|
||||||
|
"rowsViewport": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 724,
|
||||||
|
"dp": 362.0
|
||||||
|
},
|
||||||
|
"contentTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 298,
|
||||||
|
"dp": 149.0
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 1300,
|
||||||
|
"dp": 650.0
|
||||||
|
},
|
||||||
|
"padding": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 40,
|
||||||
|
"dp": 20.0
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 2,
|
||||||
|
"dp": 1.0
|
||||||
|
},
|
||||||
|
"radius": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 22,
|
||||||
|
"dp": 11.0
|
||||||
|
},
|
||||||
|
"titleBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 16,
|
||||||
|
"dp": 8.0
|
||||||
|
},
|
||||||
|
"paragraphBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 14,
|
||||||
|
"dp": 7.0
|
||||||
|
},
|
||||||
|
"actionsTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 36,
|
||||||
|
"dp": 18.0
|
||||||
|
},
|
||||||
|
"actionGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 24,
|
||||||
|
"dp": 12.0
|
||||||
|
},
|
||||||
|
"progressTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 30,
|
||||||
|
"dp": 15.0
|
||||||
|
},
|
||||||
|
"progressBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 22,
|
||||||
|
"dp": 11.0
|
||||||
|
},
|
||||||
|
"progressHeight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 12,
|
||||||
|
"dp": 6.0
|
||||||
|
},
|
||||||
|
"progressRadius": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 8,
|
||||||
|
"dp": 4.0
|
||||||
|
},
|
||||||
|
"autoTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 862,
|
||||||
|
"dp": 431.0
|
||||||
|
},
|
||||||
|
"toggleWidth": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 80,
|
||||||
|
"dp": 40.0
|
||||||
|
},
|
||||||
|
"toggleHeight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 42,
|
||||||
|
"dp": 21.0
|
||||||
|
},
|
||||||
|
"toggleThumb": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 30,
|
||||||
|
"dp": 15.0
|
||||||
|
},
|
||||||
|
"toggleInset": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 6,
|
||||||
|
"dp": 3.0
|
||||||
|
},
|
||||||
|
"notesHeight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 512,
|
||||||
|
"dp": 256.0
|
||||||
|
},
|
||||||
|
"notesGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 18,
|
||||||
|
"dp": 9.0
|
||||||
|
},
|
||||||
|
"notesScrollStep": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 80,
|
||||||
|
"dp": 40.0
|
||||||
|
},
|
||||||
|
"backTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 184,
|
||||||
|
"dp": 92.0
|
||||||
|
},
|
||||||
|
"scanFraction": {
|
||||||
|
"$type": "number",
|
||||||
|
"$value": 0.28
|
||||||
|
},
|
||||||
|
"scanEnd": {
|
||||||
|
"$type": "number",
|
||||||
|
"$value": 4.6
|
||||||
|
},
|
||||||
|
"sheenAlpha": {
|
||||||
|
"$type": "number",
|
||||||
|
"$value": 0.35
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue