Compare commits
50 commits
fm/tv-stab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fc10b7af2 | |||
| 9061384ad1 | |||
| 9bbb4bab25 | |||
| 57cf17aaad | |||
| 51799669e7 | |||
| 383802611c | |||
| 94a69de25f | |||
| c78a8dfa8d | |||
| d171aaae9e | |||
| 83ebf25d9f | |||
| 37ad87d058 | |||
| 2b8c729f62 | |||
| 271cee2cea | |||
| ee3f3ae11e | |||
| 19df50c0f1 | |||
| 7a0cbdf909 | |||
| e189d9318a | |||
| 152dac97c0 | |||
| 19bb587c95 | |||
| 165a3f5e7d | |||
| c3e9b3cd3b | |||
| 017004bf39 | |||
| b44519f01b | |||
| a0237fa975 | |||
| c2f7ccc438 | |||
| fe44a3c24f | |||
| 00e8e83bb6 | |||
| b80b67f736 | |||
| fdff85c8a1 | |||
| 332fc6476d | |||
| d18a05bdb0 | |||
| 1668445418 | |||
| fd48426fa7 | |||
| 052d0b8898 | |||
| acb72335e3 | |||
| 53a2cff2c8 | |||
| 327f631f0d | |||
| e340d470c1 | |||
| f4fb6e22c9 | |||
| 5bd2affc1a | |||
| 8e40ac0869 | |||
| 54c23e076a | |||
|
|
159edc875d | ||
| 12fd12f754 | |||
| 7b88f9b54b | |||
| 5162c299db | |||
| 9d2471c5b1 | |||
| 278335f009 | |||
| deb24e3c9f | |||
|
|
bd46ff3e03 |
239 changed files with 20552 additions and 856 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
|
||||
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:
|
||||
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
|
||||
|
||||
|
|
@ -19,3 +19,13 @@ test: build-image
|
|||
|
||||
release-tag:
|
||||
@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.
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ val gitDescribe =
|
|||
.standardOutput.asText
|
||||
.getOrElse("v0.0.0")
|
||||
|
||||
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||
apply(from = "household-version.gradle.kts")
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||
|
|
@ -228,6 +232,13 @@ configure<ApplicationExtension> {
|
|||
|
||||
androidComponents {
|
||||
onVariants(selector().all()) { variant ->
|
||||
if (variant.name == "defaultOptimized") {
|
||||
variant.outputs.forEach {
|
||||
it.versionName.set(tvVersion)
|
||||
// The sentinel is configuration-only: optimized tasks require the explicit input above.
|
||||
it.versionCode.set(tvVersionCode.map(String::toInt).orElse(1))
|
||||
}
|
||||
}
|
||||
variant.outputs
|
||||
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
||||
.forEach { output ->
|
||||
|
|
|
|||
18
app/household-version.gradle.kts
Normal file
18
app/household-version.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Household assembly inputs remain mandatory even through aggregate Gradle tasks.
|
||||
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||
|
||||
fun requireHouseholdVersion() {
|
||||
require(tvVersionCode.isPresent) { "Household builds require -PtvVersionCode=<monotonic code>" }
|
||||
require(tvVersionCode.get().toInt() in 1..2100000000) { "Invalid household versionCode" }
|
||||
require(Regex("""\d+\.\d+\.\d+""").matches(tvVersion.get())) { "Household versionName must be plain major.minor.patch" }
|
||||
}
|
||||
|
||||
if (gradle.startParameter.taskNames.any { it.contains("Optimized", ignoreCase = true) }) {
|
||||
requireHouseholdVersion()
|
||||
}
|
||||
tasks.configureEach {
|
||||
if (name.contains("DefaultOptimized", ignoreCase = true)) {
|
||||
doFirst { requireHouseholdVersion() }
|
||||
}
|
||||
}
|
||||
|
|
@ -36,10 +36,18 @@
|
|||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.INSTALL_PACKAGE" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
|
@ -69,6 +77,13 @@
|
|||
<data android:scheme="wholphin" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name="org.justinvisser.wolfskin.tv.update.UpdateInstallReceiver"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name="org.justinvisser.wolfskin.tv.update.UpdateConfirmationActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
|
|
@ -576,12 +577,22 @@ class MainActivityViewModel
|
|||
val restoredSession =
|
||||
if (signInAutomatically) {
|
||||
Timber.v("Sign in automatically")
|
||||
serverRepository.restoreLastSession()
|
||||
serverRepository.restoreSavedSession()
|
||||
} else {
|
||||
serverRepository.getMostRecentServer()
|
||||
}
|
||||
Timber.d("Restore last session result=%s", restoredSession)
|
||||
setupNavigationManager.navigateTo(restoredSession.destination)
|
||||
if (signInAutomatically && restoredSession is RestoredSession.Success) {
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
serverRepository.revalidateSession(restoredSession.currentUser)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
Timber.w(exception, "Saved launch session revalidation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import org.acra.ktx.initAcra
|
|||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import timber.log.Timber
|
||||
import org.justinvisser.wolfskin.tv.NetworkStartup
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||
|
|
@ -54,6 +55,7 @@ class WholphinApplication :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
networkStartup.start()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy
|
||||
|
|
@ -110,6 +112,9 @@ class WholphinApplication :
|
|||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var networkStartup: NetworkStartup
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() =
|
||||
Configuration
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ interface JellyfinServerDao {
|
|||
return user.copy(rowId = result.toInt())
|
||||
}
|
||||
|
||||
/** Network name refreshes must not overwrite settings or protection changed since launch. */
|
||||
@Query("UPDATE users SET name = :name WHERE serverId = :serverId AND id = :userId")
|
||||
fun updateUserName(serverId: UUID, userId: UUID, name: String?): Int
|
||||
|
||||
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||
fun getUser(
|
||||
serverId: UUID,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
|||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -101,15 +103,7 @@ class ServerRepository
|
|||
}
|
||||
Timber.v("Changing user to ${user.name} on ${server.url}")
|
||||
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
||||
val userDto by apiClient.userApi.getCurrentUser()
|
||||
val updatedServer =
|
||||
try {
|
||||
val sysInfo by apiClient.systemApi.getPublicSystemInfo()
|
||||
server.copy(name = sysInfo.serverName, version = sysInfo.version)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception fetching public system info")
|
||||
server
|
||||
}
|
||||
val (updatedServer, userDto) = readSession(server)
|
||||
var updatedUser =
|
||||
user.copy(
|
||||
id = userDto.id,
|
||||
|
|
@ -125,6 +119,45 @@ class ServerRepository
|
|||
return@withContext currentUser
|
||||
}
|
||||
|
||||
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */
|
||||
suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) {
|
||||
val recent = mostRecentServerProvider.get()
|
||||
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
|
||||
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
|
||||
val user = saved.users.firstOrNull { it.id == recent.userId }
|
||||
if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server)
|
||||
apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken)
|
||||
val current = CurrentUser(saved.server, user)
|
||||
_currentUserDto.value = null
|
||||
_current.value = current
|
||||
RestoredSession.Success(current)
|
||||
}
|
||||
|
||||
/** A late launch refresh must never replace a person selected while it was in flight. */
|
||||
suspend fun revalidateSession(expected: CurrentUser) = withContext(ioDispatcher) {
|
||||
val (server, userDto) = readSession(expected.server)
|
||||
if (_current.value !== expected || userDto.id != expected.user.id) return@withContext
|
||||
serverDao.addOrUpdateServer(server)
|
||||
serverDao.updateUserName(expected.server.id, expected.user.id, userDto.name)
|
||||
val user = serverDao.getUser(expected.server.id, expected.user.id) ?: return@withContext
|
||||
if (_current.compareAndSet(expected, CurrentUser(server, user))) updateUserDto(userDto)
|
||||
}
|
||||
|
||||
private suspend fun readSession(server: JellyfinServer): Pair<JellyfinServer, UserDto> = coroutineScope {
|
||||
val system = async {
|
||||
try {
|
||||
val info by apiClient.systemApi.getPublicSystemInfo()
|
||||
server.copy(name = info.serverName, version = info.version)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
Timber.w(exception, "Exception fetching public system info")
|
||||
server
|
||||
}
|
||||
}
|
||||
val user by apiClient.userApi.getCurrentUser()
|
||||
system.await() to user
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to restore the most recent session
|
||||
*
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
|||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
|
@ -627,7 +628,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
val UpdateUrl =
|
||||
AppStringPreference<AppPreferences>(
|
||||
title = R.string.update_url,
|
||||
defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
|
||||
defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID),
|
||||
getter = { it.updateUrl },
|
||||
setter = { prefs, value ->
|
||||
prefs.update { updateUrl = value }
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ class NavigationManager
|
|||
log()
|
||||
}
|
||||
|
||||
/** Replace only the current page, preserving every parent entry. */
|
||||
fun replaceTop(destination: Destination) {
|
||||
if (backStack.isEmpty()) backStack.add(destination)
|
||||
else backStack[backStack.lastIndex] = destination
|
||||
log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the specified [Destination], but reset the back stack to Home first
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.justinvisser.wolfskin.tv.update.AndroidUpdateService
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateTransport
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
|
@ -43,6 +46,7 @@ import java.io.OutputStream
|
|||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import javax.inject.Provider
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
|
@ -55,6 +59,7 @@ class UpdateChecker
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
private val household: Provider<AndroidUpdateService>,
|
||||
) {
|
||||
companion object {
|
||||
const val ASSET_NAME = "Wolfskin"
|
||||
|
|
@ -123,6 +128,10 @@ class UpdateChecker
|
|||
updateUrl: String,
|
||||
showNegativeToast: Boolean = false,
|
||||
) {
|
||||
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||
household.get().controller.check(manual = showNegativeToast)
|
||||
return
|
||||
}
|
||||
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val now = Date()
|
||||
val lastUpdateCheckThreshold =
|
||||
|
|
@ -181,6 +190,12 @@ class UpdateChecker
|
|||
* so this searches the release list instead of fetching a tag.
|
||||
*/
|
||||
suspend fun getRelease(version: Version): Release? {
|
||||
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||
return withContext(WholphinDispatchers.IO) {
|
||||
val releases = UpdateTransport().releasesForLatest(household.get().channelUrl())
|
||||
findReleaseByVersion(releases, version)?.let { parseRelease(it, false) }
|
||||
}
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
|
|
@ -233,6 +248,9 @@ class UpdateChecker
|
|||
release: Release,
|
||||
callback: DownloadCallback,
|
||||
) {
|
||||
check(context.packageName != UpdateChannel.PACKAGE) {
|
||||
"Household updates require the verified update controller"
|
||||
}
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
cleanup()
|
||||
val request =
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
import org.justinvisser.wolfskin.conductarr.DnsConnectionRetry
|
||||
import java.io.File
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -91,12 +94,14 @@ object AppModule {
|
|||
@StandardOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpClient() =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.apply {
|
||||
// TODO user agent, timeouts, logging, etc
|
||||
}.build()
|
||||
fun okHttpClient(dns: PersistentDns) =
|
||||
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
|
||||
|
||||
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun dns(@ApplicationContext context: Context): PersistentDns =
|
||||
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import coil3.network.NetworkResponse
|
|||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.Options
|
||||
import coil3.request.crossfade
|
||||
import coil3.request.transitionFactory
|
||||
import org.justinvisser.wolfskin.design.NetworkArtTransition
|
||||
import coil3.util.DebugLogger
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -89,7 +90,7 @@ fun CoilConfig(
|
|||
memoryCache(null)
|
||||
diskCache(null)
|
||||
}
|
||||
}.crossfade(false)
|
||||
}.transitionFactory(NetworkArtTransition)
|
||||
.logger(if (debugLogging) DebugLogger() else null)
|
||||
.components {
|
||||
add(
|
||||
|
|
|
|||
|
|
@ -82,9 +82,11 @@ class PlayExternalViewModel
|
|||
) : ViewModel() {
|
||||
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||
val state = MutableStateFlow(PlayExternalState())
|
||||
private var watchScope: String? = null
|
||||
|
||||
fun init(destination: Destination) {
|
||||
Timber.v("init called: %s", destination)
|
||||
watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||
state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchDefault {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
|
|
@ -358,6 +360,7 @@ class PlayExternalViewModel
|
|||
failed = false,
|
||||
),
|
||||
)
|
||||
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
|
||||
}
|
||||
} else {
|
||||
Timber.w(
|
||||
|
|
|
|||
|
|
@ -830,10 +830,12 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
val playbackItemState = PlaybackItemState(playback)
|
||||
val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||
val activityListener =
|
||||
TrackActivityPlaybackListener(
|
||||
api = api,
|
||||
player = player,
|
||||
onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) },
|
||||
getState = { playbackItemState },
|
||||
)
|
||||
player.addListener(activityListener)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import androidx.tv.material3.Text
|
|||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.imageLoader
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -89,6 +90,7 @@ import com.github.damontecres.wholphin.util.DataLoadingState
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -355,7 +357,9 @@ fun PreferencesContent(
|
|||
AppPreference.Update -> {
|
||||
ClickPreference(
|
||||
title =
|
||||
if (release != null && updateAvailable) {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
stringResource(R.string.check_for_updates)
|
||||
} else if (release != null && updateAvailable) {
|
||||
stringResource(R.string.install_update)
|
||||
} else if (!preferences.autoCheckForUpdates && release == null) {
|
||||
stringResource(R.string.check_for_updates)
|
||||
|
|
@ -364,7 +368,9 @@ fun PreferencesContent(
|
|||
},
|
||||
onClick = {
|
||||
if (movementSounds) playOnClickSound(context)
|
||||
if (release != null && updateAvailable) {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||
} else if (release != null && updateAvailable) {
|
||||
release?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.UpdateApp,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.DownloadCallback
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -73,6 +74,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import org.justinvisser.wolfskin.tv.update.ClassicUpdatePage
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
import org.justinvisser.wolfskin.tv.update.UpdateController
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -82,6 +86,7 @@ class UpdateViewModel
|
|||
private val userPreferencesService: UserPreferencesService,
|
||||
val updater: UpdateChecker,
|
||||
val navigationManager: NavigationManager,
|
||||
val householdController: UpdateController,
|
||||
) : ViewModel(),
|
||||
DownloadCallback {
|
||||
private val _state = MutableStateFlow(InstallUpdateState())
|
||||
|
|
@ -93,6 +98,10 @@ class UpdateViewModel
|
|||
val currentVersion = updater.getInstalledVersion()
|
||||
|
||||
fun init() {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
householdController.check(true)
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchIO {
|
||||
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
||||
|
|
@ -166,6 +175,10 @@ fun InstallUpdatePage(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: UpdateViewModel = hiltViewModel(),
|
||||
) {
|
||||
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||
ClassicUpdatePage(viewModel.householdController, modifier)
|
||||
return
|
||||
}
|
||||
OneTimeLaunchedEffect { viewModel.init() }
|
||||
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ class SwitchSeerrViewModel
|
|||
_state.update { it.copy(loading = LoadingState.Success) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nothing to check so set success
|
||||
_state.update { it.copy(loading = LoadingState.Success) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import android.content.Context
|
|||
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import com.google.auto.service.AutoService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -73,10 +78,7 @@ class CrashReportSender : ReportSender {
|
|||
|
||||
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||
(this as? MostRecentServer.ServerAndUser)?.let {
|
||||
val okHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.build()
|
||||
val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client()
|
||||
val okHttpFactory = OkHttpFactory(okHttpClient)
|
||||
val api =
|
||||
createJellyfin {
|
||||
|
|
@ -89,3 +91,11 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
|||
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
||||
api
|
||||
}
|
||||
|
||||
/** Crash reporting shares the application's DNS hints without borrowing authenticated headers. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface CrashNetwork {
|
||||
@StandardOkHttpClient
|
||||
fun client(): OkHttpClient
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import android.content.Context
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import timber.log.Timber
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/** Only an acknowledged stop invalidates the captured server/user's watch projections. */
|
||||
object PlaybackWatchState {
|
||||
private val mutableRevisions = MutableStateFlow<Map<String, Long>>(emptyMap())
|
||||
val revisions = mutableRevisions.asStateFlow()
|
||||
|
||||
/** Remove persisted watch grids before publishing the captured user's acknowledgement. */
|
||||
fun stopped(context: Context, scope: String) {
|
||||
try {
|
||||
FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope)
|
||||
} catch (exception: IOException) {
|
||||
Timber.w(exception, "Could not invalidate playback watch snapshots")
|
||||
}
|
||||
val time = System.currentTimeMillis()
|
||||
mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) }
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import com.github.damontecres.wholphin.ui.launchIO
|
|||
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
|
@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds
|
|||
class TrackActivityPlaybackListener(
|
||||
private val api: ApiClient,
|
||||
private val player: Player,
|
||||
private val onStopped: () -> Unit = {},
|
||||
private val getState: () -> PlaybackItemState?,
|
||||
) : Player.Listener {
|
||||
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
||||
private val reports = Mutex()
|
||||
@Volatile private var released = false
|
||||
private val task: TimerTask =
|
||||
object : TimerTask() {
|
||||
override fun run() {
|
||||
|
|
@ -70,19 +75,23 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
val delay = 5.seconds.inWholeMilliseconds
|
||||
// Every x seconds, check if the video is playing
|
||||
if (!released) {
|
||||
TIMER.schedule(task, delay, delay)
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
// player.removeListener(this)
|
||||
if (released) return
|
||||
released = true
|
||||
task.cancel()
|
||||
TIMER.purge()
|
||||
val position = player.currentPosition.milliseconds
|
||||
val stoppedState = getState.invoke()
|
||||
launch("reportPlaybackStopped") {
|
||||
getState.invoke()?.let { state ->
|
||||
stoppedState?.let { state ->
|
||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||
if (position < Duration.ZERO) {
|
||||
Timber.w("Negative position when reporting playback stopped: %s", position)
|
||||
|
|
@ -96,6 +105,7 @@ class TrackActivityPlaybackListener(
|
|||
liveStreamId = state.liveStreamId,
|
||||
),
|
||||
)
|
||||
onStopped()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +127,7 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
private fun saveActivity(position: Long) {
|
||||
launch("saveActivity") {
|
||||
if (released) return@launch
|
||||
getState.invoke()?.let { state ->
|
||||
val calcPosition =
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
|
|
@ -152,7 +163,7 @@ class TrackActivityPlaybackListener(
|
|||
) {
|
||||
coroutineScope.launchIO {
|
||||
try {
|
||||
block.invoke(this)
|
||||
reports.withLock { block.invoke(this) }
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception during %s", name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import io.mockk.mockkStatic
|
|||
import io.mockk.slot
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
|
@ -116,9 +118,6 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.restoreLastSession() } returns
|
||||
// RestoredSession.Success(currentUser)
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
|
|
@ -132,7 +131,7 @@ class TestMainActivityViewModel {
|
|||
destination as SetupDestination.AppContent
|
||||
Assert.assertEquals(currentUser, destination.current)
|
||||
}
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ class TestMainActivityViewModel {
|
|||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
||||
val args = mutableListOf<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
||||
|
|
@ -165,6 +164,20 @@ class TestMainActivityViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
|
||||
setupPreferences { signInAutomatically = true }
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
|
||||
val release = CompletableDeferred<Unit>()
|
||||
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() }
|
||||
viewModel.handleAppStart()
|
||||
runCurrent()
|
||||
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
|
||||
assertFalse(release.isCompleted)
|
||||
release.complete(Unit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test auto sign in, hot load, with protected profile`() =
|
||||
runTest {
|
||||
|
|
@ -178,7 +191,7 @@ class TestMainActivityViewModel {
|
|||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
} returns null
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
coEvery { serverRepository.restoreSavedSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
|
@ -204,7 +217,7 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
coEvery { serverRepository.restoreSavedSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
|
|
@ -212,7 +225,7 @@ class TestMainActivityViewModel {
|
|||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
|
|
@ -232,11 +245,11 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
||||
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
|
|
@ -254,7 +267,7 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
coEvery { serverRepository.restoreSavedSession() } returns
|
||||
RestoredSession.Success(currentUser)
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||
|
||||
|
|
@ -271,7 +284,7 @@ class TestMainActivityViewModel {
|
|||
// destination as SetupDestination.AppContent
|
||||
// Assert.assertEquals(currentUser, destination.current)
|
||||
// }
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.*
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import io.mockk.*
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
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.model.api.PublicSystemInfo
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.junit.Assert.*
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Saved identities can paint offline without unlocking protected logins or accepting a late identity. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class ServerSavedSessionTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Saved server", "https://jellyfin.test", "12.1.0")
|
||||
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Saved name", accessToken = "saved-token")
|
||||
private val users = mockk<UserApi>()
|
||||
private val system = mockk<SystemApi>()
|
||||
private val api = mockk<ApiClient>(relaxed = true)
|
||||
private val recent = mockk<MostRecentServerProvider>()
|
||||
private val database by lazy {
|
||||
Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Application>(), AppDatabase::class.java)
|
||||
.allowMainThreadQueries().build()
|
||||
}
|
||||
private val dao by lazy { database.serverDao() }
|
||||
private val dto = UserDto(id = user.id, name = "Refreshed name",
|
||||
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false)
|
||||
private val info = PublicSystemInfo(serverName = "Refreshed server", version = "12.1.0")
|
||||
private val repository by lazy { ServerRepository(mockk<Context>(), mockk(), dao, api, dispatcher, recent) }
|
||||
|
||||
@Before fun savedLogin() {
|
||||
every { recent.get() } returns MostRecentServer.from(CurrentUser(server, user))
|
||||
dao.addOrUpdateServer(server)
|
||||
user = dao.addOrUpdateUser(user)
|
||||
every { api.userApi } returns users
|
||||
every { api.systemApi } returns system
|
||||
coEvery { users.getCurrentUser() } returns successResponse(dto)
|
||||
coEvery { system.getPublicSystemInfo() } returns successResponse(info)
|
||||
}
|
||||
|
||||
@After fun closeDatabase() { database.close() }
|
||||
|
||||
@Test fun savedSessionIsUsableWhileUserRevalidationWaitsAndSystemInfoRunsInParallel() = runTest(dispatcher) {
|
||||
val pendingUser = CompletableDeferred<Unit>()
|
||||
val systemStarted = CompletableDeferred<Unit>()
|
||||
coEvery { users.getCurrentUser() } coAnswers { pendingUser.await(); successResponse(dto) }
|
||||
coEvery { system.getPublicSystemInfo() } coAnswers { systemStarted.complete(Unit); successResponse(info) }
|
||||
val restored = repository.restoreSavedSession() as RestoredSession.Success
|
||||
assertEquals(user, restored.currentUser.user)
|
||||
assertEquals(restored.currentUser, repository.current.value)
|
||||
val refresh = async { repository.revalidateSession(restored.currentUser) }
|
||||
runCurrent()
|
||||
systemStarted.await()
|
||||
assertFalse(refresh.isCompleted)
|
||||
pendingUser.complete(Unit)
|
||||
refresh.await()
|
||||
assertEquals("Refreshed name", repository.current.value!!.user.name)
|
||||
assertEquals("Refreshed server", repository.current.value!!.server.name)
|
||||
}
|
||||
|
||||
@Test fun savedProtectedPeopleRemainLocked() = runTest(dispatcher) {
|
||||
for (protected in listOf(user.copy(pin = "1234"), user.copy(requireLogin = true))) {
|
||||
dao.updateUser(protected)
|
||||
assertTrue(repository.restoreSavedSession() is RestoredSession.ServerOnly)
|
||||
assertNull(repository.current.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun lateLaunchRefreshCannotReplaceTheSelectedPerson() = runTest(dispatcher) {
|
||||
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) }
|
||||
val refresh = async { repository.revalidateSession(first) }
|
||||
entered.await()
|
||||
val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person"))
|
||||
every { recent.get() } returns MostRecentServer.from(CurrentUser(server, selected))
|
||||
repository.restoreSavedSession()
|
||||
pending.complete(Unit)
|
||||
refresh.await()
|
||||
assertEquals(selected, repository.current.value!!.user)
|
||||
assertNull(repository.currentUserDto)
|
||||
}
|
||||
|
||||
@Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) {
|
||||
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) }
|
||||
val refresh = async { repository.revalidateSession(first) }
|
||||
entered.await()
|
||||
val edited = user.copy(
|
||||
pin = "4567",
|
||||
appPreferences = user.appPreferences.copy(preferredAudioLanguage = "nld"),
|
||||
config = user.config.copy(homeSettingsSource = HomePageSettingsSource.LOCAL),
|
||||
)
|
||||
dao.updateUser(edited)
|
||||
pending.complete(Unit)
|
||||
refresh.await()
|
||||
val expected = edited.copy(name = dto.name)
|
||||
assertEquals(expected, dao.getUser(server.id, user.id))
|
||||
assertEquals(expected, repository.current.value!!.user)
|
||||
}
|
||||
|
||||
@Test fun anotherUsersResponseCannotRenameTheSavedPerson() = runTest(dispatcher) {
|
||||
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
|
||||
coEvery { users.getCurrentUser() } returns successResponse(dto.copy(id = UUID.randomUUID()))
|
||||
repository.revalidateSession(first)
|
||||
assertEquals(user, repository.current.value!!.user)
|
||||
assertNull(repository.currentUserDto)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,16 @@ import java.nio.file.Paths
|
|||
import kotlin.io.path.readText
|
||||
|
||||
class TestUpdateChecker {
|
||||
@Test
|
||||
fun householdChannelMigrationDoesNotChangeCustomOrOldProductionFeeds() {
|
||||
val channel = org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||
Assert.assertEquals(channel.LATEST_URL, channel.migrateUrl(channel.PACKAGE, channel.LEGACY_URL))
|
||||
Assert.assertEquals("https://custom.test/releases/latest", channel.migrateUrl(channel.PACKAGE, "https://custom.test/releases/latest"))
|
||||
Assert.assertEquals(channel.LEGACY_URL, channel.migrateUrl("org.justinvisser.wolfskin.tv", channel.LEGACY_URL))
|
||||
Assert.assertEquals(channel.LEGACY_URL, channel.defaultUrl("org.justinvisser.wolfskin.tv"))
|
||||
Assert.assertEquals(channel.LATEST_URL, channel.defaultUrl(channel.PACKAGE))
|
||||
}
|
||||
|
||||
lateinit var releaseJson: JsonObject
|
||||
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,14 @@ class VersionCompareTests {
|
|||
@Test
|
||||
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
||||
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
||||
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
|
||||
Assert.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun householdAvailabilityOrdersAndroidCodesNotDisplayVersions() {
|
||||
Assert.assertTrue(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 65))
|
||||
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(65, 100001))
|
||||
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 100001))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -62,16 +62,22 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import timber.log.Timber
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [DeviceModule::class, AppModule::class],
|
||||
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
|
||||
)
|
||||
object TestModule {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
|
||||
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun deviceInfo(
|
||||
|
|
@ -91,12 +97,11 @@ object TestModule {
|
|||
@StandardOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpClient() =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.apply {
|
||||
// TODO user agent, timeouts, logging, etc
|
||||
}.build()
|
||||
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ class PlayExternalViewModelTest {
|
|||
every { api.subtitleApi } returns subtitleApi
|
||||
every { api.playStateApi } returns playStateApi
|
||||
every { serverRepository.currentUser } returns null
|
||||
every { serverRepository.currentServer } returns null
|
||||
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import android.app.Application
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import androidx.media3.common.Player
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.operations.PlayStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||
import java.io.File
|
||||
|
||||
/** Return refresh waits for the stop write rather than racing its acknowledgement. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class PlaybackWatchStateTest {
|
||||
@Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val previous = WholphinDispatchers.IO
|
||||
Dispatchers.setMain(dispatcher)
|
||||
WholphinDispatchers.IO = dispatcher
|
||||
val api = mockk<ApiClient>()
|
||||
val reports = mockk<PlayStateApi>()
|
||||
every { api.playStateApi } returns reports
|
||||
val player = mockk<Player> { every { currentPosition } returns 751_246L }
|
||||
val scope = "server:${UUID.randomUUID()}"
|
||||
val another = "server:${UUID.randomUUID()}"
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))
|
||||
val saved = CacheEntry("old progress", null, 1L, 1L, group = "library")
|
||||
cache.write(scope, "library:movies", saved)
|
||||
cache.write(another, "library:movies", saved)
|
||||
val acknowledgement = CompletableDeferred<Unit>()
|
||||
coEvery { reports.reportPlaybackStopped(any()) } coAnswers {
|
||||
acknowledgement.await()
|
||||
successResponse(Unit)
|
||||
}
|
||||
val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY)
|
||||
val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state })
|
||||
try {
|
||||
listener.release()
|
||||
runCurrent()
|
||||
assertNull(PlaybackWatchState.revisions.value[scope])
|
||||
assertNotNull(cache.read(scope, "library:movies"))
|
||||
acknowledgement.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
assertNotNull(PlaybackWatchState.revisions.value[scope])
|
||||
assertNull(PlaybackWatchState.revisions.value[another])
|
||||
assertNull(cache.read(scope, "library:movies"))
|
||||
assertEquals(saved, cache.read(another, "library:movies"))
|
||||
coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) }
|
||||
listener.release()
|
||||
advanceUntilIdle()
|
||||
coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) }
|
||||
} finally {
|
||||
WholphinDispatchers.IO = previous
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val previous = WholphinDispatchers.IO
|
||||
Dispatchers.setMain(dispatcher)
|
||||
WholphinDispatchers.IO = dispatcher
|
||||
val api = mockk<ApiClient>()
|
||||
val reports = mockk<PlayStateApi>()
|
||||
every { api.playStateApi } returns reports
|
||||
coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected")
|
||||
val scope = "server:${UUID.randomUUID()}"
|
||||
val listener = TrackActivityPlaybackListener(api, mockk<Player> { every { currentPosition } returns 751_246L },
|
||||
onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) })
|
||||
try {
|
||||
listener.release()
|
||||
advanceUntilIdle()
|
||||
assertNull(PlaybackWatchState.revisions.value[scope])
|
||||
} finally {
|
||||
WholphinDispatchers.IO = previous
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.justinvisser.wolfskin.conductarr.model.ReadinessStage
|
||||
import org.justinvisser.wolfskin.design.ComingInStage
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.conductarr.model.ActivityFeed
|
||||
|
||||
/** Recorded history separates acquisition, visibility proof, entitlement and file health. */
|
||||
class ActivityTruthProjectionTest {
|
||||
private val rows = Json { ignoreUnknownKeys = true }.decodeFromString<ActivityFeed>(
|
||||
javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id }
|
||||
@Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() {
|
||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
val expected = mapOf(
|
||||
"request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof.
|
||||
"request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof.
|
||||
"dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition.
|
||||
"dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere.
|
||||
"dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor.
|
||||
"dispatch-723" to "Removed", // A historical play is not a current holding.
|
||||
"dispatch-728" to "Not out yet", // The Body: unreleased.
|
||||
"dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding.
|
||||
"request-8" to "Waiting for a file", // Partition: no active transfer is established.
|
||||
"dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed.
|
||||
)
|
||||
assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer))
|
||||
expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) }
|
||||
listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id ->
|
||||
val source = rows.getValue(id)
|
||||
val projected = source.comingIn()
|
||||
val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED }
|
||||
assertEquals(affected.summary, projected.summary)
|
||||
assertFalse(projected.readiness[1] == "Failed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() {
|
||||
val row = rows.getValue("dispatch-721")
|
||||
val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable."
|
||||
val projected = row.copy(stages = row.stages.orEmpty().map {
|
||||
when (it.name) {
|
||||
ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary)
|
||||
ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE)
|
||||
else -> it
|
||||
}
|
||||
}).comingIn()
|
||||
assertEquals(ComingInStage.FileHealth, projected.stage)
|
||||
assertEquals(summary, projected.summary)
|
||||
}
|
||||
|
||||
@Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() {
|
||||
val row = rows.getValue("request-8")
|
||||
val projected = row.copy(stages = row.stages.orEmpty().map {
|
||||
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it
|
||||
}).comingIn()
|
||||
assertEquals(ComingInStage.Waiting, projected.stage)
|
||||
}
|
||||
|
||||
@Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() {
|
||||
val row = rows.getValue("dispatch-725")
|
||||
val absent = row.copy(stages = row.stages.orEmpty().map {
|
||||
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it
|
||||
})
|
||||
assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage)
|
||||
val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } }
|
||||
assertEquals(ComingInStage.Ready, ready.comingIn().stage)
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
|
|||
}
|
||||
}
|
||||
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
|
|||
val navigation = NavigationManager()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
model.load(server, user)
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
|
|||
val client = session.identity.value!!.client
|
||||
val libraries = LibraryReadFacade(mockk())
|
||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertNull(libraries.audiences)
|
||||
|
|
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
|
|||
session.authenticate()
|
||||
val libraries = LibraryReadFacade(mockk())
|
||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
val identity = session.identity.value!!
|
||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||
|
|
|
|||
|
|
@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest {
|
|||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||
runBlocking { session.authenticate() }
|
||||
val home = mockk<HomeReadFacade>()
|
||||
coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf(
|
||||
coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf(
|
||||
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
|
||||
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ class ConductarrProjectionTest {
|
|||
assertEquals(ComingInStage.Downloading, request.stage)
|
||||
assertEquals("Mindel", request.requesterName)
|
||||
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
||||
assertEquals(1, requests.openCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ConductarrTruthCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false))
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
@Test fun requestHistory() = requests("requests-history", "request-2")
|
||||
@Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true)
|
||||
@Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true)
|
||||
@Test fun requestRemoved() = requests("requests-removed", "dispatch-725")
|
||||
@Test fun discoverMovies() = discover("movie")
|
||||
@Test fun discoverSeries() = discover("tv")
|
||||
|
||||
private fun requests(name: String, focused: String, expanded: Boolean = false) {
|
||||
FakeConductarr().use { fake ->
|
||||
fake.activityBody = resource("activity-history.json")
|
||||
ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client ->
|
||||
val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) }
|
||||
val shell = ShellState(people, "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("settings")
|
||||
requests.update(snapshot)
|
||||
openRequests()
|
||||
requests.focus(focused)
|
||||
if (expanded) requests.key(ShellKey.Right)
|
||||
}
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } }
|
||||
capture(name)
|
||||
rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0)
|
||||
rule.onNodeWithText("Requests · 56 requests").assertExists()
|
||||
if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun discover(type: String) {
|
||||
FakeConductarr().use { fake ->
|
||||
fake.feedBody = resource("mindel-feed.json")
|
||||
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
||||
val provider = ConductarrProvider(client)
|
||||
val snapshot = runBlocking { provider.discover("mindel").last() }.let { original ->
|
||||
fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null)
|
||||
original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) })
|
||||
}
|
||||
val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") }
|
||||
val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value }
|
||||
val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate))
|
||||
state.filter = type
|
||||
state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0)
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } }
|
||||
capture("discover-$type")
|
||||
rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
|
||||
/** Equal captions are not equal seed identities; repeated contributions union their evidence. */
|
||||
class DiscoverSeedProjectionTest {
|
||||
@Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking {
|
||||
FakeConductarr().use { fake ->
|
||||
val feed = fake.wire.getValue("feed").jsonObject
|
||||
val candidate = feed.getValue("movies").jsonArray.first().jsonObject
|
||||
val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[
|
||||
{"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false},
|
||||
{"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true},
|
||||
{"media_type":"movie","tmdb_id":2,"watched":true,"added":false}
|
||||
]""")))
|
||||
val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[
|
||||
{"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true}
|
||||
]""")))
|
||||
fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString()
|
||||
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
||||
val seeds = ConductarrProvider(client).discover("mindel").last().seeds
|
||||
assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key })
|
||||
assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb })
|
||||
assertTrue(seeds.first().watched && seeds.first().added)
|
||||
assertFalse(seeds.last().watched)
|
||||
assertTrue(seeds.last().added)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** A pending shelf cannot capture the D-pad or break the shell's Back ladder. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class DiscoverSkeletonKeysTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun downAndUpSkipPendingLanesAndBackReturnsToNavWhenOnlySkeletonsRemain() {
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply {
|
||||
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||
}
|
||||
val pending = DiscoverLane("pending", "Pending", emptyList(), pending = true)
|
||||
val ready = DiscoverLane("ready", "Available", listOf(DiscoverTitle(1, "movie", "A real title")))
|
||||
val snapshot = DiscoverSnapshot(status = DiscoverStatus.Ready, lanes = listOf(pending, ready))
|
||||
val state = DiscoverState(shell, snapshot, mockk(relaxed = true), CoroutineScope(Dispatchers.Main.immediate), {}, {})
|
||||
state.focus(1)
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } }
|
||||
press(Key.DirectionDown)
|
||||
rule.runOnIdle { assertEquals("ready", state.focusedLane?.id); assertEquals("ready:0", state.cardKey) }
|
||||
press(Key.DirectionUp)
|
||||
rule.runOnIdle {
|
||||
assertEquals(1, state.focusedRow)
|
||||
state.updateSnapshot(snapshot.copy(lanes = listOf(pending)))
|
||||
}
|
||||
press(Key.DirectionDown)
|
||||
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
|
||||
press(Key.Back)
|
||||
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
|
||||
}
|
||||
|
||||
private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ internal class FakeConductarr : Closeable {
|
|||
@Volatile var localArt = false
|
||||
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
||||
@Volatile var audiencesBody: String? = null
|
||||
@Volatile var feedBody: String? = null
|
||||
@Volatile var activityBody: String? = null
|
||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||
|
||||
|
|
@ -69,6 +71,8 @@ internal class FakeConductarr : Closeable {
|
|||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
||||
if (key == "audiences") audiencesBody?.let { return response(it) }
|
||||
if (key == "feed") feedBody?.let { return response(it) }
|
||||
if (key == "activity") activityBody?.let { return response(it) }
|
||||
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||
var body = wire.getValue(key).jsonObject
|
||||
|
|
|
|||
|
|
@ -84,14 +84,14 @@ class FirstSignInNavigationTest {
|
|||
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows))
|
||||
for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) {
|
||||
coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
|
||||
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
|
||||
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
|
||||
}
|
||||
val navigation = NavigationManager()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
|
||||
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -43,11 +43,13 @@ class HomeRecoveryTest {
|
|||
val started = CompletableDeferred<Unit>()
|
||||
val response = CompletableDeferred<HomeLibrary>()
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||
started.await()
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertFalse(model.snapshot.rows.first().watchReady)
|
||||
response.completeExceptionally(IOException("fixture offline"))
|
||||
refresh.await()
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
|
|
@ -62,8 +64,9 @@ class HomeRecoveryTest {
|
|||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||
val startedA = CompletableDeferred<Unit>()
|
||||
val responseA = CompletableDeferred<HomeLibrary>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
||||
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||
startedA.await()
|
||||
|
|
@ -80,7 +83,8 @@ class HomeRecoveryTest {
|
|||
|
||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } returns library
|
||||
coEvery { facade.readWatch(personA.id) } returns library
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>()
|
||||
every { cache.read(any()) } returns null
|
||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||
|
|
@ -93,7 +97,8 @@ class HomeRecoveryTest {
|
|||
|
||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } returns library
|
||||
coEvery { facade.readWatch(personA.id) } returns library
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class LibraryReadFacadeTest {
|
|||
val offsets = mutableListOf<Int>()
|
||||
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
|
||||
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
||||
assertEquals(200, offsets.last())
|
||||
|
|
|
|||
|
|
@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest {
|
|||
assertFalse(reads.last().unreachable)
|
||||
}
|
||||
|
||||
@Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking {
|
||||
var now = 1000L
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
||||
val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f))
|
||||
store.read("server:justin", "movies") { flowOf(old) }.toList()
|
||||
store.read("server:mindel", "movies") { flowOf(old) }.toList()
|
||||
now = 1002L
|
||||
val updated = listOf(old.single().copy(progress = .6f))
|
||||
val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList()
|
||||
assertEquals(.1f, reads.first().items.single().progress)
|
||||
assertEquals(.6f, reads.last().items.single().progress)
|
||||
assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items)
|
||||
assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items)
|
||||
}
|
||||
|
||||
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class PersonPickerSessionTest {
|
|||
val navigation = remember { NavigationManager() }
|
||||
val model = remember {
|
||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
|
||||
}
|
||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
|
|
@ -128,6 +128,8 @@ class PersonPickerSessionTest {
|
|||
private fun awaitLoaded(user: JellyfinUser) {
|
||||
try {
|
||||
rule.waitUntil(10_000) {
|
||||
// Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition.
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
|
||||
models.last().conductarr.identity.value?.signedIn == true &&
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val clients = mutableListOf<ConductarrClient>()
|
||||
try {
|
||||
model.load(server, justin)
|
||||
|
|
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
|
|
@ -214,8 +214,9 @@ class PersonSwitchThreadingTest {
|
|||
every { repository.currentUser } returns user
|
||||
every { repository.currentServer } returns server
|
||||
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||
coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
|
||||
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
|
||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import org.jellyfin.sdk.model.api.UserItemDataDto
|
||||
import java.util.UUID
|
||||
|
||||
/** Checkpoints use the SDK's required identity and watch fields. */
|
||||
internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto =
|
||||
UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false,
|
||||
lastPlayedDate = null, played = played, key = "", itemId = itemId)
|
||||
|
|
@ -58,7 +58,7 @@ class PlayerHandoffTest {
|
|||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||
val title = snapshot.rows.first().cards.first()
|
||||
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
|
||||
coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline")
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||
|
|
@ -80,14 +80,16 @@ class PlayerHandoffTest {
|
|||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
coEvery { facade.playableId(user.id, title) } returns id
|
||||
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
||||
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
|
||||
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
|
||||
val view = shell.view
|
||||
view.cards["hero"] = 0
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
|
||||
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||
val destination = navigation.backStack.last() as Destination.Playback
|
||||
assertEquals(id, destination.itemId)
|
||||
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
||||
assertEquals(1_136_274L, destination.positionMs)
|
||||
assertTrue(destination.fullScreen)
|
||||
navigation.goBack()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
|
|
@ -99,11 +101,11 @@ class PlayerHandoffTest {
|
|||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val otherUser = user.copy(name = "Justin")
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) }
|
||||
awaitModel { model.toast != null }
|
||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
coVerify(exactly = 0) { facade.playableId(any(), any()) }
|
||||
coVerify(exactly = 0) { facade.preparePlayback(any(), any()) }
|
||||
}
|
||||
private fun awaitModel(condition: () -> Boolean) {
|
||||
rule.waitUntil(5000) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.NavigationManager
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.PeopleStatus
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** The actual picker displays every saved login while household revalidation has no response. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class SavedPeoplePickerTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun savedPeopleAreVisibleBeforeTheFirstNetworkResponse() {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
val previousUrl = ConductarrPreference.baseUrl
|
||||
val previousFixtures = ConductarrPreference.fixtures
|
||||
val release = CountDownLatch(1)
|
||||
val entered = AtomicBoolean(false)
|
||||
FakeConductarr().use { fake ->
|
||||
fake.beforeResponse = { entered.set(true); release.await() }
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Saved server", fake.url, null)
|
||||
val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) ->
|
||||
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token)
|
||||
}
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val navigation = NavigationManager()
|
||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
|
||||
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
rule.setContent {
|
||||
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
viewModel = model, homeContent = { _, _, _, _ -> })
|
||||
}
|
||||
rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 }
|
||||
users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
|
||||
rule.runOnIdle {
|
||||
assertTrue(session.state!!.pickerVisible)
|
||||
assertEquals(PeopleStatus.Ready, session.state!!.peopleStatus)
|
||||
assertEquals(users.map { it.id.toString() }, session.state!!.people.map { it.jellyfinUserId })
|
||||
assertEquals(listOf("justin", "mindel", "shared"), session.state!!.people.map { it.key })
|
||||
assertEquals(1L, release.count)
|
||||
}
|
||||
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(captures, "saved-people-before-network.png"))
|
||||
} finally {
|
||||
release.countDown()
|
||||
model.viewModelScope.cancel()
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ class ShellStartupTimingTest {
|
|||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val load = launch { model.load(server, user) }
|
||||
try {
|
||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||
|
|
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val release = java.util.concurrent.CountDownLatch(1)
|
||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey
|
|||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performMouseInput
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
|
|
@ -62,6 +65,27 @@ class TitleKeysTest {
|
|||
} }
|
||||
}
|
||||
|
||||
@Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() {
|
||||
render("Andor") { page ->
|
||||
page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29,
|
||||
playTarget = page.playTarget!!.copy(season = 29))
|
||||
}
|
||||
press(Key.DirectionDown)
|
||||
assertEquals("seasons", state.row)
|
||||
assertEquals(28, state.column)
|
||||
rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed()
|
||||
press(Key.DirectionLeft)
|
||||
assertEquals(27, state.column)
|
||||
rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed()
|
||||
press(Key.DirectionUp)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(27, state.column)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(28, state.season)
|
||||
assertEquals(28, state.requestedSeason)
|
||||
assertEquals(TitleAction.Season(28), effects.last())
|
||||
}
|
||||
|
||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||
private fun replay(end: String): Int {
|
||||
val path = paths.single { it.getString("end") == end }
|
||||
|
|
@ -312,11 +336,6 @@ class TitleKeysTest {
|
|||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||
}
|
||||
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
|
||||
render("Andor")
|
||||
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||
}
|
||||
|
||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||
render(transform = { data ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
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.ui.nav.Destination
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
/** Production title actions preserve parents and resolve live playback instead of cached offsets. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class TitlePlaybackNavigationTest {
|
||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
private val person = ShellPerson("justin", "Justin", "J", false)
|
||||
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true)
|
||||
private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12)
|
||||
private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2)
|
||||
|
||||
@Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val navigation = NavigationManager()
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(any(), any()) } returns null
|
||||
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
||||
val id = firstArg<UUID>()
|
||||
TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()),
|
||||
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
|
||||
}
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
||||
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
|
||||
try {
|
||||
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
||||
open(seriesId)
|
||||
advanceUntilIdle()
|
||||
model.title!!.click("episodes", 0)
|
||||
assertEquals(3, navigation.backStack.size)
|
||||
assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId)
|
||||
open(firstId)
|
||||
advanceUntilIdle()
|
||||
for (id in listOf(secondId, firstId, secondId)) {
|
||||
model.title!!.click("episodes", if (id == firstId) 0 else 1)
|
||||
assertEquals(3, navigation.backStack.size)
|
||||
assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId)
|
||||
open(id)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
model.title!!.click("related", 0)
|
||||
assertEquals(4, navigation.backStack.size)
|
||||
navigation.goBack()
|
||||
model.title!!.click("cast", 0)
|
||||
assertEquals(4, navigation.backStack.size)
|
||||
navigation.goBack()
|
||||
model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false })
|
||||
assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList())
|
||||
navigation.goBack()
|
||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||
navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString())))
|
||||
open(firstId)
|
||||
advanceUntilIdle()
|
||||
model.title!!.click("episodes", 1)
|
||||
navigation.goBack()
|
||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||
} finally {
|
||||
model.viewModelScope.cancel()
|
||||
session.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val navigation = NavigationManager()
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(any(), any()) } returns null
|
||||
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
||||
TitlePageData(series, first, watchTarget = if (firstArg<UUID>() == seriesId) series else first)
|
||||
}
|
||||
coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE,
|
||||
userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2))
|
||||
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
||||
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
||||
try {
|
||||
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
||||
advanceUntilIdle()
|
||||
model.title!!.click("header", 0)
|
||||
advanceUntilIdle()
|
||||
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
||||
navigation.goBack()
|
||||
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
||||
advanceUntilIdle()
|
||||
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
||||
navigation.goBack()
|
||||
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
||||
advanceUntilIdle()
|
||||
model.title!!.click("header", 0)
|
||||
advanceUntilIdle()
|
||||
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
||||
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
||||
} finally {
|
||||
model.viewModelScope.cancel()
|
||||
session.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,8 @@ import com.github.damontecres.wholphin.test.TestTracks
|
|||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
|
|
@ -128,7 +130,7 @@ class TitleReadFacadeTest {
|
|||
}
|
||||
|
||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
|
||||
facade.read(firstId, null, preferences)
|
||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||
val saved = facade.readSaved(firstId, preferences)!!
|
||||
|
|
@ -139,6 +141,7 @@ class TitleReadFacadeTest {
|
|||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||
assertTrue(saved.item.ratings.isEmpty())
|
||||
assertEquals(firstId.toString(), saved.watchTarget.id)
|
||||
assertFalse(screen.options.any { it.id == "play" || it.id == "restart" })
|
||||
}
|
||||
|
||||
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
||||
|
|
@ -218,6 +221,43 @@ class TitleReadFacadeTest {
|
|||
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
|
||||
}
|
||||
|
||||
@Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest {
|
||||
val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42"))
|
||||
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1)
|
||||
coEvery { library.getItem(seriesId) } returns successResponse(series)
|
||||
coEvery { library.getItem(firstId) } returns successResponse(episode)
|
||||
coEvery { shows.getNextUp(any<GetNextUpRequest>()) } coAnswers {
|
||||
assertTrue(firstArg<GetNextUpRequest>().fields.orEmpty().isEmpty())
|
||||
successQueryResult(emptyList())
|
||||
}
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
|
||||
listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3)))
|
||||
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } coAnswers {
|
||||
val fields = firstArg<GetEpisodesRequest>().fields.orEmpty()
|
||||
assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) })
|
||||
successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis")))
|
||||
}
|
||||
val page = facade.read(seriesId, 3, preferences)
|
||||
assertEquals(firstId.toString(), page.playTarget?.id)
|
||||
assertEquals("MKV", page.versions.single().container)
|
||||
assertEquals(listOf(3), page.versions.single().audio.map { it.index })
|
||||
val saved = requireNotNull(facade.readSaved(seriesId, preferences))
|
||||
assertEquals(page.versions, saved.versions)
|
||||
}
|
||||
|
||||
@Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(
|
||||
BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor"))
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } coAnswers {
|
||||
val fields = firstArg<GetItemsRequest>().fields.orEmpty()
|
||||
assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) })
|
||||
assertTrue(ItemFields.PEOPLE in fields)
|
||||
successQueryResult(listOf(file(secondId).copy(people = listOf(
|
||||
org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef")))))
|
||||
}
|
||||
assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character)
|
||||
}
|
||||
|
||||
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
coEvery { playback.savePlayVersion(any(), any()) } returns null
|
||||
|
|
|
|||
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||
import org.jellyfin.sdk.api.operations.TvShowsApi
|
||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
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.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import java.util.UUID
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.github.damontecres.wholphin.util.configure
|
||||
import com.github.damontecres.wholphin.util.reset
|
||||
|
||||
/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class WatchReadTest {
|
||||
private val api = mockk<ApiClient>()
|
||||
private val library = mockk<UserLibraryApi>()
|
||||
private val shows = mockk<TvShowsApi>()
|
||||
private val items = mockk<ItemsApi>()
|
||||
private val images = mockk<ImageUrlService>(relaxed = true)
|
||||
private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e")
|
||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
// Selected fields from the read-only Resume and NextUp replies, without credentials.
|
||||
private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}"""
|
||||
private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}"""
|
||||
private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first()
|
||||
|
||||
@Before fun setup() {
|
||||
every { api.userLibraryApi } returns library
|
||||
every { api.tvShowsApi } returns shows
|
||||
every { api.itemsApi } returns items
|
||||
mockkObject(WholphinApplication.Companion)
|
||||
every { WholphinApplication.instance } returns mockk {
|
||||
every { resources } returns RuntimeEnvironment.getApplication().resources
|
||||
}
|
||||
}
|
||||
|
||||
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
|
||||
|
||||
@Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest {
|
||||
val resume = decode(resumeWire)
|
||||
val next = decode(nextWire)
|
||||
val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES)
|
||||
coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series)
|
||||
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume)
|
||||
coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next)
|
||||
coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next))
|
||||
val home = HomeReadFacade(api, images, mockk())
|
||||
val repository = mockk<ServerRepository> {
|
||||
every { currentUser } returns mockk { every { id } returns userId }
|
||||
}
|
||||
val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk<Context>())
|
||||
val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0)
|
||||
assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
|
||||
assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs())
|
||||
assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id)
|
||||
val selected = title.preparePlayback(seriesId)!!
|
||||
assertEquals(next.id, selected.id)
|
||||
assertEquals(1_136_274L, selected.playbackPositionMs())
|
||||
coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) }
|
||||
val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true))
|
||||
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed)
|
||||
assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
|
||||
assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played)
|
||||
}
|
||||
|
||||
@Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest {
|
||||
WholphinDispatchers.configure(StandardTestDispatcher(testScheduler))
|
||||
val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null)
|
||||
val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null)
|
||||
val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString())
|
||||
val resume = decode(resumeWire)
|
||||
val next = decode(nextWire)
|
||||
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume))
|
||||
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next))
|
||||
val facade = spyk(HomeReadFacade(api, images, mockk()))
|
||||
val releaseLibrary = CompletableDeferred<Unit>()
|
||||
coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") }
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L)
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||
try {
|
||||
val loading = launch { model.loadLibrary(server, user, person) }
|
||||
runCurrent()
|
||||
assertFalse(loading.isCompleted)
|
||||
assertTrue(model.snapshot.rows.first().watchReady)
|
||||
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001)
|
||||
releaseLibrary.complete(Unit)
|
||||
loading.join()
|
||||
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
} finally {
|
||||
model.viewModelScope.cancel()
|
||||
session.close()
|
||||
WholphinDispatchers.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.util.PlaybackWatchState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class WatchReturnTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() {
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
|
||||
val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString())
|
||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }
|
||||
val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9)
|
||||
val current = AtomicReference(episode)
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
every { facade.audiences } returns null
|
||||
coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) }
|
||||
coEvery { facade.readLibrary(user.id) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||
val oldFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
try {
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell) { HomeDestination(shell, server, user, it, model) }
|
||||
} }
|
||||
rule.waitUntil(5000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0
|
||||
}
|
||||
val view = shell.rememberedView("home")
|
||||
rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) }
|
||||
PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}")
|
||||
rule.waitUntil(5000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274
|
||||
}
|
||||
assertSame(view, shell.rememberedView("home"))
|
||||
assertEquals(0, view.cards["hero"])
|
||||
assertTrue(model.snapshot.rows.first().watchReady)
|
||||
} finally {
|
||||
model.viewModelScope.cancel()
|
||||
session.close()
|
||||
ConductarrPreference.updateFixtures(oldFixtures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
File diff suppressed because it is too large
Load diff
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||
import org.justinvisser.wolfskin.conductarr.CacheStore
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
|
||||
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
||||
object ConductarrPreference {
|
||||
|
|
@ -54,8 +55,8 @@ object ConductarrPreference {
|
|||
object ConductarrModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession =
|
||||
ConductarrSession(ConductarrDiskCache(context), scope)
|
||||
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession =
|
||||
ConductarrSession(ConductarrDiskCache(context), scope, dns)
|
||||
}
|
||||
|
||||
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
|
||||
|
|
|
|||
|
|
@ -41,20 +41,62 @@ internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle(
|
|||
overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(),
|
||||
)
|
||||
|
||||
/** Activity is history; stage evidence must not turn a past dispatch into a live transfer. */
|
||||
internal fun RequestProgress.comingIn(): ComingInRequest {
|
||||
val stagesByName = stages.orEmpty().associateBy { it.name }
|
||||
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
||||
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
||||
val playback = stagesByName[ReadinessStage.Name.PLAYBACK]
|
||||
val stage = when {
|
||||
stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed
|
||||
acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed
|
||||
acquisition?.summary == "The title is not released yet; there is nothing to acquire." -> ComingInStage.NotOutYet
|
||||
playback?.state == ReadinessStage.State.FAILED || playback?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.FileHealth
|
||||
visibility?.summary?.startsWith("The title is only in libraries this viewer does not see: ") == true -> ComingInStage.HeldElsewhere
|
||||
visibility?.summary == "The title is not in a library this viewer sees." -> ComingInStage.NotInLibrary
|
||||
visibility?.state == ReadinessStage.State.FAILED || visibility?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.CheckNeeded
|
||||
stagesByName[ReadinessStage.Name.REQUEST]?.state == ReadinessStage.State.FAILED || acquisition?.state == ReadinessStage.State.FAILED -> ComingInStage.Failed
|
||||
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
|
||||
awaitingApproval == true -> ComingInStage.Waiting
|
||||
visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading
|
||||
acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching
|
||||
stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking
|
||||
else -> ComingInStage.Waiting
|
||||
awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
|
||||
acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
|
||||
visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
|
||||
acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
|
||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting
|
||||
else -> ComingInStage.Checking
|
||||
}
|
||||
val detail = when (stage) {
|
||||
ComingInStage.FileHealth -> playback
|
||||
ComingInStage.CheckNeeded, ComingInStage.HeldElsewhere, ComingInStage.NotInLibrary, ComingInStage.Ready -> visibility
|
||||
ComingInStage.Failed -> stages.orEmpty().firstOrNull { it.state == ReadinessStage.State.FAILED }
|
||||
else -> acquisition
|
||||
}
|
||||
return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key,
|
||||
requester.name, actor?.requester, actor?.name, stage)
|
||||
requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary,
|
||||
readiness = ReadinessStage.Name.entries.map { stagesByName[it].readinessWord() })
|
||||
}
|
||||
|
||||
/** Expanded readiness describes each observed stage, not a synthetic download failure. */
|
||||
private fun ReadinessStage?.readinessWord(): String = when {
|
||||
this == null -> "Unknown"
|
||||
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Needs attention"
|
||||
name == ReadinessStage.Name.VISIBILITY && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Check needed"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary == "The title was deleted after it was dispatched." -> "Removed"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary == "The title is not released yet; there is nothing to acquire." -> "Not out yet"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary.startsWith("Dispatched; the Arr has represented files") -> "Files present"
|
||||
name == ReadinessStage.Name.ACQUISITION && summary == "Dispatched; waiting for the Arr to import a file." -> "Waiting for a file"
|
||||
name == ReadinessStage.Name.VISIBILITY && summary.startsWith("The title is only in libraries this viewer does not see: ") -> "Another library"
|
||||
name == ReadinessStage.Name.VISIBILITY && summary == "The title is not in a library this viewer sees." -> "Not in library"
|
||||
name == ReadinessStage.Name.ACQUISITION && state == ReadinessStage.State.IN_PROGRESS && summary.startsWith("Downloading", ignoreCase = true) -> "Downloading"
|
||||
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.DONE || state == ReadinessStage.State.PARTIAL) -> "Recorded play"
|
||||
name == ReadinessStage.Name.PLAYBACK && state == ReadinessStage.State.UNKNOWN -> "Not checked yet"
|
||||
else -> when (state) {
|
||||
ReadinessStage.State.DONE -> "Done"
|
||||
ReadinessStage.State.FAILED -> "Failed"
|
||||
ReadinessStage.State.IN_PROGRESS -> "In progress"
|
||||
ReadinessStage.State.PARTIAL -> "Partial"
|
||||
ReadinessStage.State.NONE -> "Not yet"
|
||||
ReadinessStage.State.UNKNOWN -> "Unknown"
|
||||
ReadinessStage.State.STALE -> "Last observed"
|
||||
ReadinessStage.State.CONFLICTING -> "Check needed"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,10 +57,14 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
||||
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
|
||||
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
||||
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
|
||||
val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate ->
|
||||
candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } }
|
||||
}.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) }
|
||||
val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
|
||||
val picks = candidates.map { it.discoverTitle(genres) }
|
||||
val seeds = candidates.flatMapIndexed { index, candidate ->
|
||||
candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] }
|
||||
}.groupBy { (seed, _) -> "${seed.mediaType.name.lowercase()}:${seed.tmdbId}" }.map { (key, contributions) ->
|
||||
DiscoverSeed(key, requireNotNull(contributions.first().first.title), contributions.map { it.second },
|
||||
watched = contributions.any { it.first.watched }, added = contributions.any { it.first.added })
|
||||
}
|
||||
val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
|
||||
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ data class ConductarrIdentity(
|
|||
class ConductarrSession(
|
||||
private val cache: CacheStore,
|
||||
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
private val dns: PersistentDns? = null,
|
||||
) : Closeable {
|
||||
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||
|
|
@ -50,7 +51,7 @@ class ConductarrSession(
|
|||
}
|
||||
try {
|
||||
val client = if (token.isNullOrBlank()) null else try {
|
||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), dns = dns, onEvent = { event ->
|
||||
if (event == ClientEvent.NEEDS_SIGN_IN) {
|
||||
mutableIdentity.update {
|
||||
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@ import java.util.Date
|
|||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
|
|
@ -50,9 +52,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||
catalogueJob?.cancel()
|
||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||
withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||
if (retry && conductarr.identity.value?.signedIn != true) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,11 +76,12 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
toast = null
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
||||
}
|
||||
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||
val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||
if (revision != loadRevision) return
|
||||
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||
var libraryAvailable = true
|
||||
var librarySavedAt: String? = null
|
||||
var watchReady = false
|
||||
val identity = conductarr.identity.value
|
||||
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||
fun publish() {
|
||||
|
|
@ -89,8 +92,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||
}
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
|
||||
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
||||
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
||||
}, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||
}
|
||||
catalogueSnapshot = catalogueCopy
|
||||
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
||||
|
|
@ -107,21 +111,44 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||
}
|
||||
}
|
||||
suspend fun save() {
|
||||
val library = libraryCopy
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ }
|
||||
}
|
||||
}
|
||||
coroutineScope {
|
||||
launch {
|
||||
try {
|
||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||
val savedAt = System.currentTimeMillis()
|
||||
libraryCopy = library
|
||||
val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) }
|
||||
if (revision != loadRevision) return@launch
|
||||
libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp)
|
||||
watchReady = true
|
||||
libraryAvailable = true
|
||||
publish()
|
||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } }
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (revision != loadRevision) return
|
||||
if (revision != loadRevision) return@launch
|
||||
libraryAvailable = false
|
||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||
publish()
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
try {
|
||||
val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) }
|
||||
if (revision != loadRevision) return@launch
|
||||
libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals)
|
||||
publish()
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (revision == loadRevision) save()
|
||||
}
|
||||
|
||||
suspend fun readAhead(lane: String?) {
|
||||
val identity = conductarr.identity.value ?: return
|
||||
|
|
@ -138,6 +165,7 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
val personKey = shell.person.key
|
||||
val selectedId = shell.person.jellyfinUserId
|
||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
||||
|
|
@ -146,9 +174,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
return@launch
|
||||
}
|
||||
try {
|
||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
||||
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
|
|
@ -204,7 +232,8 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
|||
@Composable
|
||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||
val identity by model.conductarrState.collectAsState()
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) }
|
||||
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
|
||||
val view = shell.rememberedView("home")
|
||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
|
||||
/** Process warm-up never blocks application creation or serializes the two service lookups. */
|
||||
@Singleton
|
||||
class NetworkStartup @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val recent: Provider<MostRecentServerProvider>,
|
||||
private val dns: PersistentDns,
|
||||
@param:IoCoroutineScope private val scope: CoroutineScope,
|
||||
) {
|
||||
fun start() {
|
||||
scope.launch {
|
||||
val url = when (val server = recent.get().get()) {
|
||||
is MostRecentServer.Server -> server.serverUrl
|
||||
is MostRecentServer.ServerAndUser -> server.serverUrl
|
||||
MostRecentServer.None -> null
|
||||
}
|
||||
warm(url)
|
||||
}
|
||||
scope.launch {
|
||||
ConductarrPreference.initialize(context)
|
||||
warm(ConductarrPreference.baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun warm(url: String?) {
|
||||
val host = url?.toHttpUrlOrNull()?.host ?: return
|
||||
try { dns.lookup(host) } catch (_: Exception) {
|
||||
// An offline launch must still render the saved people.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,6 @@ internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: ()
|
|||
refresh()
|
||||
while (currentCoroutineContext().isActive) {
|
||||
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
||||
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh()
|
||||
if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import org.justinvisser.wolfskin.design.TitleReadStatus
|
|||
import org.justinvisser.wolfskin.design.TitleState
|
||||
import org.justinvisser.wolfskin.design.TitleSurface
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
private val saveMutex = Mutex()
|
||||
private var performerPage = 1
|
||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
||||
|
||||
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
||||
fun popRoute() = navigation.goBack()
|
||||
|
|
@ -87,6 +89,16 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
||||
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true &&
|
||||
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
|
||||
!destination.startsWith("person/") && fixture == null) {
|
||||
loadJob?.cancel()
|
||||
preferences = userPreferences
|
||||
title?.let { it.update(it.data.copy(watchReady = false)) }
|
||||
loadJob = viewModelScope.launch { readTitle(Uri.decode(destination.substringAfter('/'))) }
|
||||
return
|
||||
}
|
||||
route = destination
|
||||
loadJob?.cancel()
|
||||
person = null
|
||||
|
|
@ -132,7 +144,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
if (title !== current) return
|
||||
if (heldId != null) {
|
||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
||||
held = facade.read(heldId, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
||||
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||
current.update(held)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
|
|
@ -223,13 +235,21 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
when (action) {
|
||||
TitleAction.Pop -> navigation.goBack()
|
||||
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||
is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||
is TitleAction.OpenTitle -> {
|
||||
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
||||
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id))
|
||||
if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination)
|
||||
else navigation.navigateTo(destination)
|
||||
}
|
||||
is TitleAction.Play -> {
|
||||
val opened = title?.data?.watchTarget
|
||||
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
|
||||
restart = action.restart)
|
||||
}
|
||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
||||
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
||||
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||
is TitleAction.Season -> {
|
||||
|
|
@ -261,12 +281,15 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun play(id: UUID?, position: Long) {
|
||||
private fun play(id: UUID?, restart: Boolean) {
|
||||
val current = title
|
||||
val playbackScope = watchScope
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||
facade.preparePlayback(id)
|
||||
navigation.navigateTo(Destination.Playback(id, position))
|
||||
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||
if (title !== current || watchScope != playbackScope) return@launch
|
||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||
|
|
@ -308,7 +331,8 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
@Composable
|
||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||
LaunchedEffect(title?.data?.status) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.github.damontecres.wholphin.util.PlaybackWatchState
|
||||
|
||||
/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */
|
||||
@Composable
|
||||
internal fun rememberWatchRefresh(scope: String?): Pair<Long, Int> {
|
||||
val revisions by PlaybackWatchState.revisions.collectAsState()
|
||||
var foreground by remember(scope) { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner, scope) {
|
||||
var initialResume = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
if (initialResume) initialResume = false else foreground++
|
||||
}
|
||||
}
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
return (revisions[scope] ?: 0L) to foreground
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
val seeAll: WolfskinSeeAllLists,
|
||||
val conductarr: ConductarrSession,
|
||||
artwork: WolfskinArtwork,
|
||||
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
||||
) : ViewModel() {
|
||||
init { artwork.configure(context) }
|
||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||
|
|
@ -195,7 +196,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
is ShellAction.Destination -> {
|
||||
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
||||
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
|
||||
else -> navigation.replace(target)
|
||||
}
|
||||
|
|
@ -262,6 +263,7 @@ fun WolfskinApp(
|
|||
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
||||
) {
|
||||
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) }
|
||||
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
|
||||
LaunchedEffect(identity?.signedIn, identity?.person) {
|
||||
|
|
@ -303,7 +305,13 @@ fun WolfskinApp(
|
|||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||
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.
|
||||
val state = LocalShellState.current
|
||||
val discover = LocalDiscoverState.current
|
||||
|
|
@ -329,11 +337,15 @@ fun WolfskinApp(
|
|||
} else TitleDestination(route, state, preferences)
|
||||
} else {
|
||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||
settingsInstalledVersion = installedVersion.versionName,
|
||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||
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 -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, 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/") ->
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ fun WolfskinLibraryContent(
|
|||
ShellPlaceholder("See all list unavailable", modifier)
|
||||
return
|
||||
}
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
||||
val watchRefresh = rememberWatchRefresh(cacheScope)
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) {
|
||||
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||
val wasLoading = grid.status == LibraryStatus.Loading
|
||||
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
||||
|
|
@ -82,7 +83,7 @@ fun WolfskinLibraryContent(
|
|||
grid.status = LibraryStatus.Ready
|
||||
} else {
|
||||
val store = withContext(Dispatchers.IO) { snapshots.value }
|
||||
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||
store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||
val focusedId = grid.focusedTitle?.id
|
||||
grid.source = snapshot.items
|
||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
|||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||
val audiences get() = libraries.audiences
|
||||
suspend fun read(userId: UUID): HomeLibrary {
|
||||
/** Watch rows are publishable without waiting for any library shelf. */
|
||||
suspend fun readWatch(userId: UUID): HomeLibrary {
|
||||
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
||||
|
|
@ -35,6 +36,11 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
|||
}
|
||||
val resume = resumeItems.map { title(it, series) }
|
||||
val next = nextItems.map { title(it, series) }
|
||||
return HomeLibrary(resume = resume, nextUp = next)
|
||||
}
|
||||
|
||||
/** Library failures do not discard an already successful watch read. */
|
||||
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||
val newest = mutableListOf<HomeTitle>()
|
||||
|
|
@ -46,23 +52,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
|||
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
||||
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
|
||||
}
|
||||
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
||||
return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals)
|
||||
}
|
||||
|
||||
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
|
||||
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
||||
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
||||
if (item.type == BaseItemKind.SERIES) {
|
||||
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
||||
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
||||
}
|
||||
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
||||
}
|
||||
/** Resolve the current checkpoint even when the hero was painted from disk. */
|
||||
suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? {
|
||||
val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run {
|
||||
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||
api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||
}?.id
|
||||
} ?: return null
|
||||
return readPlayback(api, userId, id)
|
||||
}
|
||||
|
||||
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
|||
val response = api.itemsApi.getItems(
|
||||
userId = userId, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset,
|
||||
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
||||
).content
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
|
|||
|
||||
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
||||
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
||||
fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
||||
fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
||||
val key = "library:$destination"
|
||||
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
||||
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
||||
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
||||
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
||||
if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||
if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||
var current: List<LibraryTitle> = emptyList()
|
||||
try {
|
||||
load().collect { chunk ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package org.justinvisser.wolfskin.tv.read
|
||||
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */
|
||||
internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? {
|
||||
val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content
|
||||
val target = if (opened.type == BaseItemKind.SERIES) {
|
||||
api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true)
|
||||
.content.items.firstOrNull()?.id
|
||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id
|
||||
} else opened.id
|
||||
val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content }
|
||||
return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE }
|
||||
}
|
||||
|
||||
/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */
|
||||
internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L
|
||||
|
|
@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.TitlePageData
|
|||
import org.justinvisser.wolfskin.design.TitlePerson
|
||||
import org.justinvisser.wolfskin.design.TitleTrack
|
||||
import org.justinvisser.wolfskin.design.TitleVersion
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
|
@ -52,7 +53,8 @@ class TitleReadFacade @Inject constructor(
|
|||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID)
|
||||
private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW)
|
||||
private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE)
|
||||
|
||||
/** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */
|
||||
suspend fun findHeld(tmdb: Long, media: String): UUID? {
|
||||
|
|
@ -76,7 +78,7 @@ class TitleReadFacade @Inject constructor(
|
|||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
||||
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
||||
build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences)
|
||||
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
|
||||
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false)
|
||||
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +89,12 @@ class TitleReadFacade @Inject constructor(
|
|||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
|
||||
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
|
||||
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) }
|
||||
}
|
||||
val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
|
||||
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
|
||||
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty()
|
||||
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
|
||||
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty()
|
||||
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) }
|
||||
val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
|
||||
val numbers = seasons.mapNotNull { it.indexNumber }
|
||||
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
||||
|
|
@ -121,8 +123,9 @@ class TitleReadFacade @Inject constructor(
|
|||
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||
}
|
||||
|
||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
||||
/** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */
|
||||
suspend fun preparePlayback(id: UUID): BaseItemDto? =
|
||||
readPlayback(api, repository.currentUser?.id, id)
|
||||
|
||||
/** Track selection uses the same repository consumed by the upstream player. */
|
||||
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
||||
|
|
@ -163,7 +166,7 @@ class TitleReadFacade @Inject constructor(
|
|||
} else emptyList()
|
||||
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
||||
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, startIndex = start, limit = 200,
|
||||
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
|
||||
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
||||
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
|
||||
|
|
@ -189,27 +192,44 @@ class TitleReadFacade @Inject constructor(
|
|||
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
||||
}
|
||||
return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) },
|
||||
rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
||||
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
||||
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
|
||||
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
||||
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
||||
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
||||
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
||||
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
||||
},
|
||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) },
|
||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
|
||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||
}
|
||||
|
||||
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem(
|
||||
private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem(
|
||||
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
|
||||
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
|
||||
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
|
||||
backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) },
|
||||
backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) },
|
||||
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
|
||||
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
|
||||
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
|
||||
)
|
||||
|
||||
private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? {
|
||||
val type = when {
|
||||
item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY
|
||||
item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB
|
||||
else -> null
|
||||
}
|
||||
if (type != null) return images.getItemImageUrl(item.id, type,
|
||||
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type))
|
||||
return seriesBackdrop ?: item.parentBackdropItemId?.let { parent ->
|
||||
item.parentBackdropImageTags?.firstOrNull()?.let { tag ->
|
||||
images.getItemImageUrl(parent, ImageType.BACKDROP,
|
||||
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
||||
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
||||
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ class ConductarrClient(
|
|||
timeouts: ClientTimeouts = ClientTimeouts(),
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
private val onEvent: (ClientEvent) -> Unit = {},
|
||||
dns: PersistentDns? = null,
|
||||
) : Closeable {
|
||||
private val base = baseUrl.toHttpUrl()
|
||||
private val scope: String
|
||||
|
|
@ -74,6 +75,7 @@ class ConductarrClient(
|
|||
private val cacheIdentityLock = Any()
|
||||
private val catalogueReads = Semaphore(2)
|
||||
private val http = OkHttpClient.Builder()
|
||||
.apply { if (dns != null) DnsConnectionRetry.configure(this, dns) }
|
||||
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
|
||||
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
|
||||
.callTimeout(timeouts.callSeconds, TimeUnit.SECONDS)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import okhttp3.Call
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
|
||||
/** Retry once with fresh DNS only if connection establishment failed before any HTTP headers were sent. */
|
||||
class DnsConnectionRetry(private val dns: PersistentDns) : Interceptor, EventListener.Factory {
|
||||
private class Attempt {
|
||||
@Volatile var connectionFailed = false
|
||||
@Volatile var headersSent = false
|
||||
}
|
||||
private val attempts = ConcurrentHashMap<Call, Attempt>()
|
||||
|
||||
override fun create(call: Call): EventListener {
|
||||
val attempt = Attempt()
|
||||
attempts[call] = attempt
|
||||
return object : EventListener() {
|
||||
override fun connectFailed(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?, ioe: IOException) {
|
||||
attempt.connectionFailed = true
|
||||
}
|
||||
override fun requestHeadersStart(call: Call) { attempt.headersSent = true }
|
||||
override fun callEnd(call: Call) { attempts.remove(call) }
|
||||
override fun callFailed(call: Call, ioe: IOException) { attempts.remove(call) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
// OkHttp deliberately disables event listeners on its derived WebSocket client.
|
||||
val attempt = attempts[chain.call()] ?: return chain.proceed(chain.request())
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (failure: IOException) {
|
||||
if (!attempt.connectionFailed || attempt.headersSent || chain.call().isCanceled()) throw failure
|
||||
try {
|
||||
dns.lookupFresh(chain.request().url.host)
|
||||
} catch (lookupFailure: IOException) {
|
||||
lookupFailure.addSuppressed(failure)
|
||||
throw lookupFailure
|
||||
}
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Install the same resolver and its pre-request recovery on a separately owned client. */
|
||||
fun configure(builder: OkHttpClient.Builder, dns: PersistentDns): OkHttpClient.Builder {
|
||||
val retry = DnsConnectionRetry(dns)
|
||||
return builder.dns(dns).eventListenerFactory(retry).addInterceptor(retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.encodeToString
|
||||
import okhttp3.Dns
|
||||
|
||||
/** Last-known system answers stay usable for one day while a single background lookup refreshes them. */
|
||||
class PersistentDns(
|
||||
private val file: () -> File,
|
||||
private val resolver: Dns = Dns.SYSTEM,
|
||||
private val executor: Executor = Executors.newFixedThreadPool(2) { task -> Thread(task, "wolfskin-dns").apply { isDaemon = true } },
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
) : Dns {
|
||||
private class Host {
|
||||
@Volatile var answer: Answer? = null
|
||||
@Volatile var refreshAfter = 0L
|
||||
var refreshing = false
|
||||
val resolutionLock = Any()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class Answer(
|
||||
val addresses: List<String>,
|
||||
val savedAtMillis: Long,
|
||||
@Transient val resolvedAddresses: List<InetAddress> = decodeAddresses(addresses),
|
||||
)
|
||||
private val hosts = ConcurrentHashMap<String, Host>()
|
||||
private val diskLock = Any()
|
||||
@Volatile private var loaded = false
|
||||
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
load()
|
||||
val host = hosts.computeIfAbsent(hostname) { Host() }
|
||||
val saved = host.answer
|
||||
if (saved != null && nowMillis() - saved.savedAtMillis in 0..MAX_STALENESS_MILLIS) {
|
||||
refresh(hostname, host)
|
||||
return saved.resolvedAddresses
|
||||
}
|
||||
return synchronized(host.resolutionLock) {
|
||||
val current = host.answer
|
||||
if (current != null && current !== saved && nowMillis() - current.savedAtMillis in 0..MAX_STALENESS_MILLIS) {
|
||||
current.resolvedAddresses
|
||||
} else resolve(hostname, host)
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed connection bypasses the cached answer and any completed background refresh. */
|
||||
fun lookupFresh(hostname: String): List<InetAddress> {
|
||||
load()
|
||||
val host = hosts.computeIfAbsent(hostname) { Host() }
|
||||
return synchronized(host.resolutionLock) {
|
||||
host.answer = null
|
||||
resolve(hostname, host)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh(hostname: String, host: Host) {
|
||||
synchronized(host) {
|
||||
if (host.refreshing || nowMillis() < host.refreshAfter) return
|
||||
host.refreshing = true
|
||||
}
|
||||
executor.execute {
|
||||
try {
|
||||
synchronized(host.resolutionLock) { resolve(hostname, host) }
|
||||
} catch (_: Exception) {
|
||||
// Failed refreshes do not extend the lifetime of the last successful answer.
|
||||
host.refreshAfter = nowMillis() + FAILURE_BACKOFF_MILLIS
|
||||
} finally {
|
||||
synchronized(host) { host.refreshing = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(hostname: String, host: Host): List<InetAddress> {
|
||||
val addresses = resolver.lookup(hostname)
|
||||
if (addresses.isEmpty()) throw UnknownHostException(hostname)
|
||||
host.answer = Answer(addresses.map { it.hostAddress!! }, nowMillis(), addresses)
|
||||
host.refreshAfter = nowMillis() + REFRESH_INTERVAL_MILLIS
|
||||
persist()
|
||||
return addresses
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
if (loaded) return
|
||||
synchronized(diskLock) {
|
||||
if (loaded) return
|
||||
try {
|
||||
val saved = file()
|
||||
if (saved.isFile && saved.length() <= MAX_FILE_BYTES) {
|
||||
val entries = WireJson.decodeFromString<Map<String, Answer>>(saved.readText())
|
||||
require(entries.size <= MAX_HOSTS)
|
||||
entries.forEach { (name, answer) -> hosts[name] = Host().apply { this.answer = answer } }
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// A missing or damaged hint file cannot prevent normal system resolution.
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Serialization and literal-validation errors invalidate the entire hint file.
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() = synchronized(diskLock) {
|
||||
try {
|
||||
val entries = hosts.entries.mapNotNull { (name, host) -> host.answer?.let { name to it } }
|
||||
.sortedByDescending { it.second.savedAtMillis }.take(MAX_HOSTS).toMap()
|
||||
val target = file()
|
||||
target.parentFile?.mkdirs()
|
||||
val temporary = File(target.path + ".tmp")
|
||||
temporary.writeText(WireJson.encodeToString(entries))
|
||||
if (!temporary.renameTo(target)) temporary.delete()
|
||||
} catch (_: IOException) {
|
||||
// The in-memory answer remains usable when private storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_STALENESS_MILLIS = 24 * 60 * 60 * 1000L
|
||||
private const val REFRESH_INTERVAL_MILLIS = 60_000L
|
||||
private const val FAILURE_BACKOFF_MILLIS = 2_000L
|
||||
private const val MAX_HOSTS = 32
|
||||
private const val MAX_ADDRESSES = 32
|
||||
private const val MAX_FILE_BYTES = 64 * 1024L
|
||||
private fun decodeAddresses(addresses: List<String>): List<InetAddress> {
|
||||
require(addresses.isNotEmpty() && addresses.size <= MAX_ADDRESSES)
|
||||
return addresses.map { address ->
|
||||
// Numeric literals only: decoding the hint file must never perform DNS.
|
||||
require(address.contains(':') || address.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+")))
|
||||
InetAddress.getByName(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.Executor
|
||||
import okhttp3.Dns
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
|
@ -14,13 +18,24 @@ fun main() = runBlocking {
|
|||
try {
|
||||
MockWebServer().use { server ->
|
||||
server.start()
|
||||
val baseUrl = server.url("/").toString()
|
||||
val baseUrl = server.url("/").newBuilder().host("localhost").build().toString()
|
||||
var resolutions = 0
|
||||
val dead = InetAddress.getByName("127.0.0.2")
|
||||
val live = InetAddress.getByName("127.0.0.1")
|
||||
val dns = PersistentDns({ File(directory, "dns.json") }, Dns {
|
||||
resolutions++
|
||||
if (resolutions == 1) listOf(dead) else listOf(live)
|
||||
}, Executor { it.run() })
|
||||
check(dns.lookup("localhost") == listOf(dead))
|
||||
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build())
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = dns).use { client ->
|
||||
val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value
|
||||
check(live.saved.value!!.profiles!!.single().name == "Justin")
|
||||
check(resolutions == 2)
|
||||
println("DNS SMOKE: cached 127.0.0.2 refused -> fresh 127.0.0.1 -> profile Justin; requests=${server.requestCount}, resolutions=$resolutions")
|
||||
}
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
|
||||
val reopened = PersistentDns({ File(directory, "dns.json") }, Dns { listOf(live) }, Executor { it.run() })
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = reopened).use { client ->
|
||||
val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
|
||||
check(saved.saved.fromDisk)
|
||||
server.enqueue(MockResponse.Builder().code(304).build())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.Dns
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/** OkHttp's listener-free WebSocket client must retain a working upgrade and message path. */
|
||||
class DnsConnectionRetryTest {
|
||||
@get:Rule val directory = TemporaryFolder()
|
||||
|
||||
@Test fun derivedClientOpensWebSocketWithoutAnUncaughtDispatcherException() {
|
||||
val opened = CompletableFuture<Int>()
|
||||
val message = CompletableFuture<String>()
|
||||
val uncaught = AtomicReference<Throwable?>()
|
||||
val executor = Executors.newCachedThreadPool { task ->
|
||||
Thread(task, "dns-websocket-test").apply {
|
||||
uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, failure ->
|
||||
uncaught.set(failure)
|
||||
opened.completeExceptionally(failure)
|
||||
message.completeExceptionally(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) { webSocket.send("ready") }
|
||||
}).build())
|
||||
server.start()
|
||||
val dns = PersistentDns({ directory.root.resolve("dns.json") }, Dns { listOf(InetAddress.getByName("127.0.0.1")) })
|
||||
val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns)
|
||||
.dispatcher(Dispatcher(executor)).build().newBuilder().build()
|
||||
val socket = client.newWebSocket(Request.Builder().url(server.url("/events")).build(), object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(response.code) }
|
||||
override fun onMessage(webSocket: WebSocket, text: String) { message.complete(text) }
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
opened.completeExceptionally(t)
|
||||
message.completeExceptionally(t)
|
||||
}
|
||||
})
|
||||
try {
|
||||
assertEquals(101, opened.get(5, TimeUnit.SECONDS).toInt())
|
||||
assertEquals("ready", message.get(5, TimeUnit.SECONDS))
|
||||
assertNull(uncaught.get())
|
||||
println("WEBSOCKET SMOKE: derived cached-DNS client opened HTTP 101, received ready, no uncaught dispatcher exception")
|
||||
} finally {
|
||||
socket.cancel()
|
||||
client.connectionPool.evictAll()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
import java.net.Proxy
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dns
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/** Fake time and queued refreshes prove stale answers, disk hints and dead-address recovery. */
|
||||
class PersistentDnsTest {
|
||||
@get:Rule val directory = TemporaryFolder()
|
||||
private var now = 1_000_000L
|
||||
private val queued = ArrayDeque<Runnable>()
|
||||
private val background = Executor { queued.add(it) }
|
||||
private val old = InetAddress.getByName("127.0.0.2")
|
||||
private val fresh = InetAddress.getByName("127.0.0.1")
|
||||
private var answer = listOf(old)
|
||||
private var lookups = 0
|
||||
private val resolver = Dns { lookups++; answer }
|
||||
private val file get() = File(directory.root, "dns.json")
|
||||
private fun cache(resolve: Dns = resolver) = PersistentDns({ file }, resolve, background, { now })
|
||||
|
||||
@Test fun staleAnswerReturnsBeforeBackgroundRefreshAndNextLookupUsesChangedIp() {
|
||||
val dns = cache()
|
||||
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
|
||||
now += 120_000
|
||||
answer = listOf(fresh)
|
||||
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
|
||||
assertEquals(1, lookups)
|
||||
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
|
||||
assertEquals(1, queued.size)
|
||||
queued.removeFirst().run()
|
||||
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
|
||||
assertEquals(2, lookups)
|
||||
}
|
||||
|
||||
@Test fun answersOlderThanOneDayCannotBeServedEvenWhenRefreshFails() {
|
||||
val dns = cache()
|
||||
dns.lookup("jellyfin.test")
|
||||
now += PersistentDns.MAX_STALENESS_MILLIS + 1
|
||||
val expired = cache(Dns { throw UnknownHostException() })
|
||||
assertThrows(UnknownHostException::class.java) { expired.lookup("jellyfin.test") }
|
||||
}
|
||||
|
||||
@Test fun persistedAnswersRetainTimestampAndSystemAddressOrdering() {
|
||||
answer = listOf(InetAddress.getByName("::1"), fresh)
|
||||
cache().lookup("jellyfin.test")
|
||||
now += 120_000
|
||||
val reopened = cache(Dns { error("Disk answer must return before resolving") })
|
||||
assertEquals(answer, reopened.lookup("jellyfin.test"))
|
||||
assertEquals(1, queued.size)
|
||||
now += PersistentDns.MAX_STALENESS_MILLIS
|
||||
assertThrows(UnknownHostException::class.java) {
|
||||
cache(Dns { throw UnknownHostException() }).lookup("jellyfin.test")
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun corruptOrNonnumericDiskAnswersAreIgnored() {
|
||||
for (contents in listOf("not json", "{\"jellyfin.test\":{\"addresses\":[\"not-an-ip.test\"],\"savedAtMillis\":1000000}}")) {
|
||||
file.writeText(contents)
|
||||
answer = listOf(fresh)
|
||||
assertEquals(listOf(fresh), cache().lookup("jellyfin.test"))
|
||||
}
|
||||
assertEquals(2, lookups)
|
||||
}
|
||||
|
||||
@Test fun negativeAnswersDoNotDelayRecoveryAndFailedRefreshDoesNotExtendStaleness() {
|
||||
var offline = true
|
||||
val dns = cache(Dns { if (offline) throw UnknownHostException(); listOf(fresh) })
|
||||
assertThrows(UnknownHostException::class.java) { dns.lookup("jellyfin.test") }
|
||||
offline = false
|
||||
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
|
||||
offline = true
|
||||
now += 120_000
|
||||
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
|
||||
queued.removeFirst().run()
|
||||
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
|
||||
assertTrue(queued.isEmpty())
|
||||
now += 2_001
|
||||
dns.lookup("jellyfin.test")
|
||||
assertEquals(1, queued.size)
|
||||
}
|
||||
|
||||
@Test fun unexpectedRefreshExceptionKeepsTheLastAnswerAndAllowsAnotherRefresh() {
|
||||
var failing = false
|
||||
val dns = cache(Dns { if (failing) throw IllegalStateException("resolver unavailable"); answer })
|
||||
dns.lookup("jellyfin.test")
|
||||
now += 120_000
|
||||
failing = true
|
||||
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
|
||||
queued.removeFirst().run()
|
||||
now += 2_001
|
||||
failing = false
|
||||
answer = listOf(fresh)
|
||||
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
|
||||
queued.removeFirst().run()
|
||||
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
|
||||
}
|
||||
|
||||
@Test fun failedCachedAddressForcesFreshResolutionAndRetriesBeforeSendingPost() {
|
||||
MockWebServer().use { server ->
|
||||
server.start()
|
||||
val dns = cache()
|
||||
dns.lookup("jellyfin.test")
|
||||
answer = listOf(fresh)
|
||||
server.enqueue(MockResponse.Builder().body("new address").build())
|
||||
val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns)
|
||||
.proxy(Proxy.NO_PROXY).retryOnConnectionFailure(false).connectTimeout(1, TimeUnit.SECONDS)
|
||||
.connectionPool(ConnectionPool(0, 1, TimeUnit.MILLISECONDS)).build()
|
||||
try {
|
||||
val url = server.url("/").newBuilder().host("jellyfin.test").build()
|
||||
client.newCall(Request.Builder().url(url).post("body".toRequestBody()).build()).execute().use {
|
||||
assertEquals("new address", it.body.string())
|
||||
}
|
||||
assertEquals(2, lookups)
|
||||
assertEquals(1, server.requestCount)
|
||||
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
|
||||
} finally {
|
||||
client.connectionPool.evictAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun backgroundIpChangeIsUsedByTheNextHttpCall() {
|
||||
MockWebServer().use { server ->
|
||||
server.start()
|
||||
val dns = cache()
|
||||
dns.lookup("jellyfin.test")
|
||||
now += 120_000
|
||||
answer = listOf(fresh)
|
||||
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
|
||||
queued.removeFirst().run()
|
||||
server.enqueue(MockResponse.Builder().body("refreshed").build())
|
||||
val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).proxy(Proxy.NO_PROXY).build()
|
||||
try {
|
||||
val url = server.url("/").newBuilder().host("jellyfin.test").build()
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { assertEquals("refreshed", it.body.string()) }
|
||||
assertEquals(2, lookups)
|
||||
assertEquals(1, server.requestCount)
|
||||
} finally {
|
||||
client.connectionPool.evictAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
data/tv-spec/spec.md
Normal file
115
data/tv-spec/spec.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Wolfskin TV specification — revision 6
|
||||
|
||||
2026-11-07. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`, and the subsequently approved **option B** [card metadata frames](https://pages.justinvisser.org/wolfskin/tv-card-meta-frames.html). Revision-5 feedback below still applies except where the card amendment explicitly supersedes it. Unchanged navigation, typography and colours remain governed by the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`.
|
||||
|
||||
## Who’s watching
|
||||
|
||||
The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person.
|
||||
|
||||
## Focus visibility
|
||||
|
||||
“Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule.
|
||||
|
||||
The focused card and its ring remain fully inside the viewport, with at least the Level 2 gap (40 px) of horizontal clearance at each screen edge. Focused rows clear the nav band and the bottom edge. Preserve the approved 168 px heading pin and initial fully-visible-row exceptions; hero/header anchors remain zero. First entry, row changes, row ends and Back from a title must reconcile retained logical selection with the actual scroll position. A frame is recorded as positioned only after its scroll finishes. Discover uses the approved six poster slots, not a rounded-down five-slot approximation.
|
||||
|
||||
Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it.
|
||||
|
||||
## Episode artwork and dimensions
|
||||
|
||||
Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it.
|
||||
|
||||
Choose available art in this order:
|
||||
|
||||
1. Episode `Primary` image — Jellyfin stores episode stills under this type.
|
||||
2. Episode `Thumb` image.
|
||||
3. Series `Backdrop`, centre-cropped to the episode slot.
|
||||
4. The existing missing-art treatment when none is available.
|
||||
|
||||
A series-page read supplies its already-read backdrop to the episode projection; it does not make one series request per episode. Saved pages rebuild the same projection from cached metadata. Thumbnail reads and prefetch use the 400×280 px episode request, not the unrelated poster dimensions.
|
||||
|
||||
Read-only Jellyfin evidence: all ten The Bear season-three episodes have Primary tags and no episode Backdrop tags. Tomorrow’s Primary source is 2100×1182; the series Backdrop is 3840×2160. These are existing aired files, not missing/unaired episode placeholders.
|
||||
|
||||
## Episode summaries and series header spacing
|
||||
|
||||
In episode context, the summary belongs to the episode: focused episode first, explicitly opened episode next, otherwise the Up-next episode. Use the show overview only when no episode context is selected. An absent episode overview stays absent instead of substituting the show's description. The focused episode card can show two ellipsised lines of its own overview below its episode label.
|
||||
|
||||
A held series header measures its actual copy, actions and action-caption band, then leaves **40 px** before the Seasons row. Remove the unconditional 900 px series-header spacer. Films, non-held titles and loading/error layouts retain their approved fixed header geometry. Measured series geometry also drives the frame offsets and focus-follow calculation, preventing season/episode rows from scrolling to stale offsets. Long titles or summaries may grow the header; controls must never overlap the seasons.
|
||||
|
||||
## Hero image quality and memory
|
||||
|
||||
| Surface | Requested/decode bound | Format policy |
|
||||
|---|---|---|
|
||||
| Home and Discover hero; title backdrop | 1920×1080 | ARGB, 7.91 MiB at the bound |
|
||||
| Upright hero poster | 420×630 | RGB565 where supported, 0.50 MiB |
|
||||
| Episode card | 400×280 | ARGB, 0.43 MiB |
|
||||
| Library poster, ordinary tile/wide, cast | Existing slot dimensions | Existing RGB565/ARGB policy |
|
||||
|
||||
Jellyfin image URLs discard conflicting width/height/max/fill caps and request the consuming slot while preserving image tags, quality and other query parameters. Jellyfin quality remains **96**. TMDB heroes use the original source rather than the previous 780 px source; Coil constrains decoding to the panel. Upright TMDB sheets use a suitable poster tier. Intentionally blurred poster-only washes remain bounded to the upright source, not an oversized portrait bitmap. Decode fitting prevents allocation of an oversized off-ratio source merely to fill a crop.
|
||||
|
||||
The shared memory cache remains **64 MiB**, disk cache **128 MiB**, image concurrency **4**. Three 1080p hero bitmaps consume 23.73 MiB, leaving 40.27 MiB of the cache for other art. These are allocation bounds, not measured TV resident-memory claims; do not request or decode 4K hero bitmaps on the 2 GB armv7 box.
|
||||
|
||||
Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. With quality 96, Jellyfin returned a 1281×720 JPEG of 438,329 bytes for the old fill request and a 1921×1080 JPEG of 982,435 bytes for the new request; the extra source pixel is Jellyfin aspect-ratio rounding. The display decode stays bounded to 1920×1080.
|
||||
|
||||
## Discover lane spacing
|
||||
|
||||
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
|
||||
|
||||
## Motion and loading — revision 6
|
||||
|
||||
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
|
||||
|
||||
| Transition | Duration and curve | Transform |
|
||||
|---|---|---|
|
||||
| Focus scale/ring | 180 ms, ease-out (`.33,1,.68,1`) | Existing locked focus scale; no geometry change |
|
||||
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
|
||||
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
|
||||
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
|
||||
| Initial hero/title copy | 180 ms, ease-out | 16 px rise |
|
||||
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
|
||||
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry |
|
||||
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
|
||||
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
||||
|
||||
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static.
|
||||
|
||||
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 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.
|
||||
|
||||
`Settings.Global.ANIMATOR_DURATION_SCALE == 0`, exposed by Compose's window `MotionDurationScale`, or the host's `LocalReducedMotion` flag disables pulse and translation/scroll motion. Focus scale and nav geometry snap; permitted fades are capped at **80 ms**. The window context supplies live scale state without settings or disk reads on the main thread. Fetch/decode dispatchers and image URL logic are unchanged.
|
||||
|
||||
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
|
||||
|
||||
|
||||
## Card metadata and bottom state line — revision 6, option B
|
||||
|
||||
Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration.
|
||||
|
||||
Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer.
|
||||
|
||||
| Geometry at 1920×1080 | Pixels |
|
||||
|---|---:|
|
||||
| Library row pitch | 524 |
|
||||
| Home poster shelf pitch | 632 |
|
||||
| Home wide / tile shelf pitch | 522 / 441 |
|
||||
| Initial library art row tops | 372 / 896 |
|
||||
| Pinned library art row tops / second-row bottom | 168 / 692 / 1076 |
|
||||
| Focus ring to caption / caption to next focused row ring | 20.48 / 26.48 |
|
||||
|
||||
One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them.
|
||||
|
||||
Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line.
|
||||
|
||||
Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid.
|
||||
|
||||
Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers.
|
||||
|
||||
## Sources and verification
|
||||
|
||||
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
||||
|
||||
`MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point.
|
||||
|
||||
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue