Compare commits
67 commits
fm/tv-walk
...
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 | |||
| 556d633e88 | |||
| 34087d8eb5 | |||
| 33dc65bf18 | |||
| ef3ae3f21b | |||
| d0e38306b3 | |||
| 5306f8ddc4 | |||
| 59f4a06815 | |||
| 427b18cdc5 | |||
| 93a41ea2d3 | |||
| 1c21f50acb | |||
| e8d0c52088 | |||
| db2541feff | |||
| f37ad0f0fe | |||
| 92c5661be0 | |||
| b9ea593735 | |||
| 711dbebf28 | |||
| c17447c528 | |||
|
|
bd46ff3e03 |
252 changed files with 21678 additions and 1040 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
|
||||
|
|
@ -133,6 +137,13 @@ configure<ApplicationExtension> {
|
|||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
|
||||
create("optimized") {
|
||||
initWith(getByName("release"))
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
applicationIdSuffix = ".opt"
|
||||
matchingFallbacks += "release"
|
||||
}
|
||||
}
|
||||
flavorDimensions += "version"
|
||||
productFlavors {
|
||||
|
|
@ -199,6 +210,10 @@ configure<ApplicationExtension> {
|
|||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||
kotlin.directories += "src/wolfskin/java"
|
||||
}
|
||||
getByName("optimized") {
|
||||
kotlin.directories += "src/release/java"
|
||||
baselineProfiles.directories += "src/optimized/baselineProfiles"
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
|
|
@ -217,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 ->
|
||||
|
|
@ -228,6 +250,11 @@ androidComponents {
|
|||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
if (variant.name == "defaultOptimized") {
|
||||
tasks.withType<Test>().configureEach {
|
||||
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
|
||||
}
|
||||
}
|
||||
if (variant.name == "defaultDebug") {
|
||||
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
||||
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
||||
|
|
|
|||
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
|
||||
TIMER.schedule(task, delay, delay)
|
||||
initialized = true
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles.
|
||||
Lcom/github/damontecres/wholphin/WholphinApplication;
|
||||
HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V
|
||||
Lcom/github/damontecres/wholphin/MainActivity;
|
||||
HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V
|
||||
Lorg/justinvisser/wolfskin/tv/WolfskinAppKt;
|
||||
Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference;
|
||||
Lorg/justinvisser/wolfskin/tv/ConductarrPreference;
|
||||
4
app/src/optimized/res/values/strings.xml
Normal file
4
app/src/optimized/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
|
||||
</resources>
|
||||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
|
@ -18,6 +19,19 @@ class VersionCompareTests {
|
|||
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
||||
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
||||
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
|
||||
fun testIsAtLeast() {
|
||||
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import mockwebserver3.RecordedRequest
|
|||
|
||||
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
||||
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
|
||||
internal const val JUSTIN_TOKEN = "11111111111111111111111111111111"
|
||||
|
||||
/** A loopback server supplies wire JSON, never app projections or live service state. */
|
||||
internal class FakeConductarr : Closeable {
|
||||
|
|
@ -28,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=")
|
||||
|
||||
|
|
@ -68,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
|
||||
|
|
@ -75,6 +80,9 @@ internal class FakeConductarr : Closeable {
|
|||
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
||||
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
|
||||
}
|
||||
if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) {
|
||||
body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin")))
|
||||
}
|
||||
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
|
||||
body = JsonObject(body + ("media_type" to JsonPrimitive("tv")))
|
||||
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import io.mockk.*
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** A selected identity gets a new app ViewModel without opening a second launch picker. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class PersonPickerSessionTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var previousUrl: String
|
||||
private var previousFixtures = false
|
||||
private var previousScreens = true
|
||||
private val models = mutableListOf<WolfskinShellViewModel>()
|
||||
private lateinit var session: WolfskinSession
|
||||
|
||||
@Before fun capturePreferences() {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
WolfskinScreenPreference.initialize(rule.activity)
|
||||
previousUrl = ConductarrPreference.baseUrl
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
previousScreens = WolfskinScreenPreference.enabled
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
WolfskinScreenPreference.enabled = true
|
||||
}
|
||||
|
||||
@After fun restorePreferences() {
|
||||
models.forEach { it.viewModelScope.cancel() }
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
WolfskinScreenPreference.enabled = previousScreens
|
||||
}
|
||||
|
||||
private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) {
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", 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)
|
||||
}
|
||||
var current by mutableStateOf(CurrentUser(server, users[initial]))
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current }
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
coEvery { libraries.hasAnimeTitles(any()) } returns false
|
||||
session = WolfskinSession()
|
||||
try {
|
||||
rule.setContent {
|
||||
key(current.user.id) {
|
||||
val navigation = remember { NavigationManager() }
|
||||
val model = remember {
|
||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
|
||||
}
|
||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
viewModel = model, homeContent = { _, _, user, modifier ->
|
||||
androidx.tv.material3.Text("Home for ${user.name}", modifier)
|
||||
})
|
||||
}
|
||||
}
|
||||
awaitLoaded(users[initial])
|
||||
if (fromAvatar) {
|
||||
press(listOf(Key.DirectionCenter))
|
||||
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
|
||||
}
|
||||
val actions = mutableListOf<ShellAction.Person>()
|
||||
rule.runOnIdle {
|
||||
val dispatch = session.onAction
|
||||
session.onAction = { action ->
|
||||
if (action is ShellAction.Person) actions.add(action)
|
||||
dispatch(action)
|
||||
}
|
||||
}
|
||||
if (fast) press(moves + Key.DirectionCenter) else {
|
||||
press(moves)
|
||||
press(listOf(Key.DirectionCenter))
|
||||
}
|
||||
awaitLoaded(users[selected])
|
||||
rule.runOnIdle {
|
||||
assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible)
|
||||
assertEquals("home", session.state!!.route)
|
||||
assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId)
|
||||
assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId })
|
||||
assertEquals(if (initial == selected) 1 else 2, models.size)
|
||||
}
|
||||
rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed()
|
||||
} finally { models.forEach { it.viewModelScope.cancel() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 &&
|
||||
models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true &&
|
||||
session.state?.people?.size == 3 &&
|
||||
models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive }
|
||||
}
|
||||
} catch (timeout: ComposeTimeoutException) {
|
||||
throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " +
|
||||
"identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " +
|
||||
"person=${session.state?.person}, people=${session.state?.people?.size}", timeout)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun press(keys: List<Key>) {
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
|
||||
}
|
||||
|
||||
@Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight))
|
||||
@Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft))
|
||||
@Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight))
|
||||
@Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft))
|
||||
@Test fun initialPersonNeedsOnlyOneOk() = select(0, 0)
|
||||
@Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||
@Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft))
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import io.mockk.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
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.*
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class PersonSwitchThreadingTest {
|
||||
private val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
private lateinit var previousUrl: String
|
||||
private var previousFixtures = false
|
||||
|
||||
@Before fun setUp() {
|
||||
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||
ConductarrPreference.initialize(context)
|
||||
previousUrl = ConductarrPreference.baseUrl
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
Dispatchers.resetMain()
|
||||
unmockkConstructor(ConductarrClient::class)
|
||||
}
|
||||
|
||||
@Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking {
|
||||
assertSame(Looper.getMainLooper(), Looper.myLooper())
|
||||
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||
val releaseCloses = CountDownLatch(1)
|
||||
val closed = CountDownLatch(4)
|
||||
val disposalErrors = CopyOnWriteArrayList<Throwable>()
|
||||
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) })
|
||||
val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
mockkConstructor(ConductarrClient::class)
|
||||
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||
closeThreads.add(onMain)
|
||||
check(!onMain) { "StrictMode: Conductarr socket close on main" }
|
||||
check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" }
|
||||
callOriginal()
|
||||
closed.countDown()
|
||||
if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure")
|
||||
}
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
val clients = mutableListOf<ConductarrClient>()
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
clients.add(conductarr.identity.value!!.client)
|
||||
val shell = session.state!!
|
||||
shell.key(ShellKey.Ok)
|
||||
for (index in listOf(1, 2, 0)) {
|
||||
val switched = CompletableDeferred<CurrentUser>()
|
||||
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||
shell.focusNav(shell.tabs.size)
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.focusPerson(index)
|
||||
shell.key(ShellKey.Ok)
|
||||
assertEquals(users[index].id.toString(), shell.person.jellyfinUserId)
|
||||
assertEquals("home", shell.route)
|
||||
assertFalse(shell.pickerVisible)
|
||||
assertNull("Old identity must not serve the newly selected person", conductarr.identity.value)
|
||||
val current = withTimeout(5_000) { switched.await() }
|
||||
assertEquals(users[index].id, current.user.id)
|
||||
model.load(current.server, current.user)
|
||||
requireNotNull(model.onForeground(server, current.user)).join()
|
||||
assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester)
|
||||
assertEquals(users[index].name!!.lowercase(), model.signedInPerson)
|
||||
clients.add(conductarr.identity.value!!.client)
|
||||
}
|
||||
conductarr.close()
|
||||
conductarr.close()
|
||||
model.viewModelScope.cancel()
|
||||
releaseCloses.countDown()
|
||||
assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) })
|
||||
assertEquals(listOf(false, false, false, false), closeThreads.toList())
|
||||
for (client in clients) {
|
||||
val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last()
|
||||
assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error)
|
||||
}
|
||||
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||
assertTrue("A failing socket close must be contained", disposalErrors.isEmpty())
|
||||
} finally {
|
||||
releaseCloses.countDown()
|
||||
model.viewModelScope.cancel()
|
||||
withContext(Dispatchers.IO) { conductarr.close() }
|
||||
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||
disposalScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking {
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
coEvery { repository.changeUser(server, any()) } coAnswers {
|
||||
val user = secondArg<JellyfinUser>()
|
||||
if (user.id == users[1].id) {
|
||||
pending.complete(Unit)
|
||||
withContext(NonCancellable) { release.await() }
|
||||
}
|
||||
CurrentUser(server, user)
|
||||
}
|
||||
val switched = CompletableDeferred<CurrentUser>()
|
||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
val shell = session.state!!
|
||||
shell.key(ShellKey.Ok)
|
||||
for (index in listOf(1, 2, 0)) {
|
||||
shell.focusNav(shell.tabs.size)
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.focusPerson(index)
|
||||
shell.key(ShellKey.Ok)
|
||||
if (index == 1) withTimeout(5_000) { pending.await() }
|
||||
}
|
||||
release.complete(Unit)
|
||||
val current = withTimeout(5_000) { switched.await() }
|
||||
assertEquals(justin.id, current.user.id)
|
||||
verify(exactly = 1) { setup.navigateTo(any()) }
|
||||
model.load(server, current.user)
|
||||
requireNotNull(model.onForeground(server, current.user)).join()
|
||||
assertEquals(justin.id.toString(), shell.person.jellyfinUserId)
|
||||
assertEquals("justin", model.signedInPerson)
|
||||
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||
} finally {
|
||||
release.complete(Unit)
|
||||
model.viewModelScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking {
|
||||
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||
val closed = CountDownLatch(3)
|
||||
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
mockkConstructor(ConductarrClient::class)
|
||||
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||
closeThreads.add(onMain)
|
||||
check(!onMain) { "StrictMode: retry socket close on main" }
|
||||
callOriginal()
|
||||
closed.countDown()
|
||||
}
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||
val repository = mockk<ServerRepository>()
|
||||
every { repository.currentUser } returns user
|
||||
every { repository.currentServer } returns server
|
||||
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||
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)
|
||||
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository)
|
||||
try {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
||||
conductarr.authenticate()
|
||||
fake.status = 401
|
||||
conductarr.authenticate()
|
||||
assertFalse(conductarr.identity.value!!.signedIn)
|
||||
fake.status = 200
|
||||
homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true)
|
||||
withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } }
|
||||
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||
assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } })
|
||||
fake.status = 401
|
||||
conductarr.authenticate()
|
||||
assertFalse(conductarr.identity.value!!.signedIn)
|
||||
fake.status = 200
|
||||
titleModel.retrySignIn()
|
||||
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||
assertTrue(conductarr.identity.value!!.signedIn)
|
||||
} finally {
|
||||
homeModel.viewModelScope.cancel()
|
||||
titleModel.viewModelScope.cancel()
|
||||
conductarr.close()
|
||||
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||
disposalScope.cancel()
|
||||
}
|
||||
assertEquals(0L, closed.count)
|
||||
assertEquals(listOf(false, false, false), closeThreads.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,17 +58,18 @@ class ShellStartupTimingTest {
|
|||
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
|
||||
withTimeout(5_000) { activityEntered.await() }
|
||||
val savedPeople = CompletableDeferred<Unit>()
|
||||
val savedPeopleStarted = CompletableDeferred<Unit>()
|
||||
val repository = mockk<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null }
|
||||
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null }
|
||||
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||
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 {
|
||||
yield()
|
||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
|
||||
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
|
||||
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
|
||||
|
|
@ -120,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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
|
|
@ -35,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleVersion
|
|||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -152,7 +154,10 @@ class TitleAsyncFocusTest {
|
|||
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
||||
rule.runOnIdle { viewModel.title!!.toast = null }
|
||||
press(Key.DirectionCenter)
|
||||
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null }
|
||||
rule.waitUntil(timeoutMillis = 5000) {
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
reads.get() == 2 && viewModel.title?.toast != null
|
||||
}
|
||||
rule.waitForIdle()
|
||||
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
||||
assertEquals("20:42", shell.navSavedAt)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
|||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||
data.episodes.forEach { item ->
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
||||
}
|
||||
}
|
||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||
|
|
@ -215,4 +215,5 @@ class TitleCaptureTest {
|
|||
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -16,7 +17,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
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 {
|
||||
|
|
@ -50,6 +55,15 @@ object ConductarrPreference {
|
|||
object ConductarrModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun session(@ApplicationContext context: Context): ConductarrSession =
|
||||
ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr")))
|
||||
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. */
|
||||
private class ConductarrDiskCache(context: Context) : CacheStore {
|
||||
private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) }
|
||||
override fun read(scope: String, key: String) = store.read(scope, key)
|
||||
override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity)
|
||||
override fun remove(scope: String, key: String) = store.remove(scope, key)
|
||||
override fun clear(scope: String) = store.clear(scope)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@ package org.justinvisser.wolfskin.tv
|
|||
|
||||
import java.io.Closeable
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.justinvisser.wolfskin.conductarr.*
|
||||
import org.justinvisser.wolfskin.conductarr.model.Operator
|
||||
import timber.log.Timber
|
||||
|
||||
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
||||
data class ConductarrIdentity(
|
||||
|
|
@ -21,53 +26,86 @@ data class ConductarrIdentity(
|
|||
)
|
||||
|
||||
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
||||
class ConductarrSession(private val cache: CacheStore) : Closeable {
|
||||
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()
|
||||
private var selection: ByteArray? = null
|
||||
@Volatile private var generation = 0L
|
||||
|
||||
@Synchronized
|
||||
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
||||
val fingerprint = MessageDigest.getInstance("SHA-256")
|
||||
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
||||
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||
generation++
|
||||
val currentGeneration = generation
|
||||
mutableIdentity.value?.client?.close()
|
||||
mutableIdentity.value = null
|
||||
selection = fingerprint
|
||||
if (token.isNullOrBlank()) return
|
||||
val client = try {
|
||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
||||
if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) {
|
||||
mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) }
|
||||
}
|
||||
})
|
||||
} catch (_: IllegalArgumentException) { return }
|
||||
mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client))
|
||||
val previous: ConductarrClient?
|
||||
val currentGeneration: Long
|
||||
synchronized(this) {
|
||||
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||
generation++
|
||||
currentGeneration = generation
|
||||
previous = mutableIdentity.value?.client
|
||||
mutableIdentity.value = null
|
||||
selection = fingerprint
|
||||
}
|
||||
try {
|
||||
val client = if (token.isNullOrBlank()) null else try {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (_: IllegalArgumentException) { null }
|
||||
synchronized(this) {
|
||||
if (generation == currentGeneration) {
|
||||
mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) }
|
||||
} else dispose(client)
|
||||
}
|
||||
} finally {
|
||||
dispose(previous)
|
||||
}
|
||||
}
|
||||
|
||||
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
|
||||
suspend fun authenticate() {
|
||||
val selected = mutableIdentity.value ?: return
|
||||
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
||||
if (mutableIdentity.value?.client !== selected.client) return@collect
|
||||
when (result) {
|
||||
is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) }
|
||||
is ReadResult.Value -> mutableIdentity.update {
|
||||
if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it
|
||||
}
|
||||
is ReadResult.Failure -> mutableIdentity.update {
|
||||
if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error)
|
||||
else it?.copy(error = result.error)
|
||||
if (it?.client !== selected.client) it
|
||||
else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error)
|
||||
else it.copy(error = result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove read authority synchronously; socket disposal finishes independently on IO. */
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
generation++
|
||||
mutableIdentity.value?.client?.close()
|
||||
val previous = mutableIdentity.value?.client
|
||||
mutableIdentity.value = null
|
||||
selection = null
|
||||
dispose(previous)
|
||||
}
|
||||
|
||||
private fun dispose(client: ConductarrClient?) {
|
||||
if (client == null) return
|
||||
// The application scope outlives the screen replaced by a person switch.
|
||||
disposalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
client.close()
|
||||
} catch (_: Exception) {
|
||||
Timber.w("Conductarr client disposal failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,11 @@ 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) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
}
|
||||
}
|
||||
loadLibrary(server, user, person, retry)
|
||||
}
|
||||
|
|
@ -72,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() {
|
||||
|
|
@ -87,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() } }
|
||||
|
|
@ -105,20 +111,43 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||
val savedAt = System.currentTimeMillis()
|
||||
libraryCopy = library
|
||||
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
|
||||
libraryAvailable = false
|
||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||
publish()
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
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 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()
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
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?) {
|
||||
|
|
@ -136,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)
|
||||
|
|
@ -144,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)
|
||||
|
|
@ -202,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) {
|
||||
|
|
@ -210,7 +222,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
internal suspend fun retrySignIn() {
|
||||
val user = repository.currentUser ?: return
|
||||
val server = repository.currentServer ?: return
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
withContext(Dispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
}
|
||||
conductarr.authenticate()
|
||||
}
|
||||
|
||||
|
|
@ -221,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) {
|
||||
"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)
|
||||
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))
|
||||
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 -> {
|
||||
|
|
@ -259,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
|
||||
|
|
@ -306,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
|
||||
}
|
||||
|
|
@ -39,6 +39,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -60,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
|
|||
class WolfskinSession @Inject constructor() {
|
||||
var state by mutableStateOf<ShellState?>(null)
|
||||
var serverId: String? = null
|
||||
internal var fixtureMode: Boolean? = null
|
||||
var onAction: (ShellAction) -> Unit = {}
|
||||
}
|
||||
|
||||
|
|
@ -76,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
|
||||
|
|
@ -85,23 +90,28 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
var signedInPerson by mutableStateOf("")
|
||||
private set
|
||||
private var identityJob: kotlinx.coroutines.Job? = null
|
||||
private var fixtureMode: Boolean? = null
|
||||
private var personSwitchJob: kotlinx.coroutines.Job? = null
|
||||
private val personSwitchMutex = Mutex()
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||
WolfskinScreenPreference.initialize(context)
|
||||
ConductarrPreference.initialize(context)
|
||||
withContext(Dispatchers.IO) {
|
||||
WolfskinScreenPreference.initialize(context)
|
||||
ConductarrPreference.initialize(context)
|
||||
}
|
||||
identityJob?.cancel()
|
||||
libraries.audiences = null
|
||||
if (ConductarrPreference.fixtures) conductarr.close()
|
||||
else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl)
|
||||
else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
|
||||
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
||||
session.onAction = { action -> handleAction(action, server, user) }
|
||||
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
|
||||
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
|
||||
session.serverId = server.id.toString()
|
||||
fixtureMode = ConductarrPreference.fixtures
|
||||
session.state = ShellState(readShellPeople(context, listOf(user)), signedInPerson) { session.onAction(it) }
|
||||
session.fixtureMode = ConductarrPreference.fixtures
|
||||
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
|
||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
||||
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||
session.state?.updatePeople(readShellPeople(context, saved.ifEmpty { listOf(user) }))
|
||||
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
||||
session.state?.updatePeople(savedPeople)
|
||||
}
|
||||
identityJob = viewModelScope.launch {
|
||||
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||
|
|
@ -149,8 +159,10 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||
retry = conductarr.identity.value?.signedIn != true)
|
||||
withContext(Dispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||
retry = conductarr.identity.value?.signedIn != true)
|
||||
}
|
||||
loadHousehold(user)
|
||||
}
|
||||
|
||||
|
|
@ -184,14 +196,14 @@ 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)
|
||||
}
|
||||
}
|
||||
ShellAction.Pop -> navigation.goBack()
|
||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
||||
is ShellAction.Person -> selectPerson(action.person, server)
|
||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||
|
|
@ -201,26 +213,29 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
|
||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
||||
navigation.replace(Destination.Home())
|
||||
if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return
|
||||
if (person.key == signedInPerson) return
|
||||
identityJob?.cancel()
|
||||
personSwitchJob?.cancel()
|
||||
signedInPerson = ""
|
||||
conductarr.close()
|
||||
libraries.audiences = null
|
||||
viewModelScope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||
}
|
||||
if (saved == null || saved.isProtected) {
|
||||
signedInPerson = ""
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val current = repository.changeUser(server, saved)
|
||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
signedInPerson = ""
|
||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||
personSwitchJob = viewModelScope.launch {
|
||||
personSwitchMutex.withLock {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||
}
|
||||
if (saved == null || saved.isProtected) return@launch
|
||||
try {
|
||||
val current = repository.changeUser(server, saved)
|
||||
coroutineContext.ensureActive()
|
||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
coroutineContext.ensureActive()
|
||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -248,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) {
|
||||
|
|
@ -289,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
|
||||
|
|
@ -315,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)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ fun WolfskinLibraryContent(
|
|||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||
}
|
||||
}
|
||||
val snapshots = remember(context) { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) }
|
||||
val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } }
|
||||
val grid = remember(shell.person.key, route, view) {
|
||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
|
||||
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||
|
|
@ -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)
|
||||
|
|
@ -81,7 +82,8 @@ fun WolfskinLibraryContent(
|
|||
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
||||
grid.status = LibraryStatus.Ready
|
||||
} else {
|
||||
snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||
val store = withContext(Dispatchers.IO) { snapshots.value }
|
||||
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 } }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
|||
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||
@Singleton
|
||||
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
||||
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
|
||||
private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) }
|
||||
|
||||
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||
val entry = store.read(scope, KEY) ?: return null
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
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 {
|
||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||
}?.id
|
||||
/** 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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