Compare commits
77 commits
fm/tv-fide
...
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 | |||
| 6e0f9952cf | |||
| c17447c528 | |||
| 9e15dbee8c | |||
| 53b8358317 | |||
| 5a84666074 | |||
| 22850632ec | |||
| afff383b75 | |||
| d401ebc355 | |||
| e794dec253 | |||
| 4e6377b628 | |||
| e9a92eba49 | |||
|
|
bd46ff3e03 |
260 changed files with 22696 additions and 1066 deletions
12
Makefile
12
Makefile
|
|
@ -4,7 +4,7 @@ WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle
|
||||||
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
|
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
|
||||||
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
|
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
|
||||||
|
|
||||||
.PHONY: build-image apk apk-debug test release-tag
|
.PHONY: build-image apk apk-debug test release-tag tv-release tv-publish tv-tooling-test
|
||||||
build-image:
|
build-image:
|
||||||
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
|
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
|
||||||
|
|
||||||
|
|
@ -19,3 +19,13 @@ test: build-image
|
||||||
|
|
||||||
release-tag:
|
release-tag:
|
||||||
@bash docker/release-tag.sh
|
@bash docker/release-tag.sh
|
||||||
|
|
||||||
|
TV_RELEASE_DIR ?= /var/tmp/wolfskin-tv-release-$(VERSION)
|
||||||
|
tv-release: build-image
|
||||||
|
@python3 docker/tv_release.py --version "$(VERSION)" --version-code "$(VERSION_CODE)" --output "$(TV_RELEASE_DIR)" $(TV_RELEASE_FLAGS)
|
||||||
|
|
||||||
|
tv-publish:
|
||||||
|
@python3 docker/tv_publish.py --directory "$(TV_RELEASE_DIR)" $(TV_PUBLISH_FLAGS)
|
||||||
|
|
||||||
|
tv-tooling-test:
|
||||||
|
@python3 -m unittest discover -s docker -p 'test_tv_*.py' -v
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ Wolfskin is an Android TV client for one household. It is released through Forge
|
||||||
|
|
||||||
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
||||||
|
|
||||||
|
## Wolfskin TV updates
|
||||||
|
|
||||||
|
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
|
||||||
|
|
||||||
|
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state.
|
||||||
|
|
||||||
|
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Wholphin - an OSS Android TV client for Jellyfin
|
# Wholphin - an OSS Android TV client for Jellyfin
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ val gitDescribe =
|
||||||
.standardOutput.asText
|
.standardOutput.asText
|
||||||
.getOrElse("v0.0.0")
|
.getOrElse("v0.0.0")
|
||||||
|
|
||||||
|
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||||
|
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||||
|
apply(from = "household-version.gradle.kts")
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||||
|
|
@ -133,6 +137,13 @@ configure<ApplicationExtension> {
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
create("optimized") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
applicationIdSuffix = ".opt"
|
||||||
|
matchingFallbacks += "release"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
flavorDimensions += "version"
|
flavorDimensions += "version"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
|
|
@ -199,6 +210,10 @@ configure<ApplicationExtension> {
|
||||||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||||
kotlin.directories += "src/wolfskin/java"
|
kotlin.directories += "src/wolfskin/java"
|
||||||
}
|
}
|
||||||
|
getByName("optimized") {
|
||||||
|
kotlin.directories += "src/release/java"
|
||||||
|
baselineProfiles.directories += "src/optimized/baselineProfiles"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
|
|
@ -217,6 +232,13 @@ configure<ApplicationExtension> {
|
||||||
|
|
||||||
androidComponents {
|
androidComponents {
|
||||||
onVariants(selector().all()) { variant ->
|
onVariants(selector().all()) { variant ->
|
||||||
|
if (variant.name == "defaultOptimized") {
|
||||||
|
variant.outputs.forEach {
|
||||||
|
it.versionName.set(tvVersion)
|
||||||
|
// The sentinel is configuration-only: optimized tasks require the explicit input above.
|
||||||
|
it.versionCode.set(tvVersionCode.map(String::toInt).orElse(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
variant.outputs
|
variant.outputs
|
||||||
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
||||||
.forEach { output ->
|
.forEach { output ->
|
||||||
|
|
@ -228,6 +250,11 @@ androidComponents {
|
||||||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||||
output.outputFileName = outputFileName
|
output.outputFileName = outputFileName
|
||||||
}
|
}
|
||||||
|
if (variant.name == "defaultOptimized") {
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
if (variant.name == "defaultDebug") {
|
if (variant.name == "defaultDebug") {
|
||||||
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
||||||
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
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" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.INSTALL_PACKAGE" />
|
||||||
|
<data android:mimeType="application/vnd.android.package-archive" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:hasFragileUserData="true"
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|
@ -69,6 +77,13 @@
|
||||||
<data android:scheme="wholphin" />
|
<data android:scheme="wholphin" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<receiver
|
||||||
|
android:name="org.justinvisser.wolfskin.tv.update.UpdateInstallReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name="org.justinvisser.wolfskin.tv.update.UpdateConfirmationActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -576,12 +577,22 @@ class MainActivityViewModel
|
||||||
val restoredSession =
|
val restoredSession =
|
||||||
if (signInAutomatically) {
|
if (signInAutomatically) {
|
||||||
Timber.v("Sign in automatically")
|
Timber.v("Sign in automatically")
|
||||||
serverRepository.restoreLastSession()
|
serverRepository.restoreSavedSession()
|
||||||
} else {
|
} else {
|
||||||
serverRepository.getMostRecentServer()
|
serverRepository.getMostRecentServer()
|
||||||
}
|
}
|
||||||
Timber.d("Restore last session result=%s", restoredSession)
|
Timber.d("Restore last session result=%s", restoredSession)
|
||||||
setupNavigationManager.navigateTo(restoredSession.destination)
|
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.Jellyfin
|
||||||
import org.jellyfin.sdk.model.ServerVersion
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import org.justinvisser.wolfskin.tv.NetworkStartup
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||||
|
|
@ -54,6 +55,7 @@ class WholphinApplication :
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
networkStartup.start()
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy
|
StrictMode.ThreadPolicy
|
||||||
|
|
@ -110,6 +112,9 @@ class WholphinApplication :
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var networkStartup: NetworkStartup
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() =
|
get() =
|
||||||
Configuration
|
Configuration
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ interface JellyfinServerDao {
|
||||||
return user.copy(rowId = result.toInt())
|
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")
|
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||||
fun getUser(
|
fun getUser(
|
||||||
serverId: UUID,
|
serverId: UUID,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -101,15 +103,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
Timber.v("Changing user to ${user.name} on ${server.url}")
|
Timber.v("Changing user to ${user.name} on ${server.url}")
|
||||||
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
||||||
val userDto by apiClient.userApi.getCurrentUser()
|
val (updatedServer, userDto) = readSession(server)
|
||||||
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
|
|
||||||
}
|
|
||||||
var updatedUser =
|
var updatedUser =
|
||||||
user.copy(
|
user.copy(
|
||||||
id = userDto.id,
|
id = userDto.id,
|
||||||
|
|
@ -125,6 +119,45 @@ class ServerRepository
|
||||||
return@withContext currentUser
|
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
|
* 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.PreferenceScreenOption
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
@ -627,7 +628,7 @@ sealed interface AppPreference<Pref, T> {
|
||||||
val UpdateUrl =
|
val UpdateUrl =
|
||||||
AppStringPreference<AppPreferences>(
|
AppStringPreference<AppPreferences>(
|
||||||
title = R.string.update_url,
|
title = R.string.update_url,
|
||||||
defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
|
defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID),
|
||||||
getter = { it.updateUrl },
|
getter = { it.updateUrl },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.update { updateUrl = value }
|
prefs.update { updateUrl = value }
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ class NavigationManager
|
||||||
log()
|
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
|
* 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.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import org.justinvisser.wolfskin.tv.update.AndroidUpdateService
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateTransport
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -43,6 +46,7 @@ import java.io.OutputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import javax.inject.Provider
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
@ -55,6 +59,7 @@ class UpdateChecker
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
|
private val household: Provider<AndroidUpdateService>,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val ASSET_NAME = "Wolfskin"
|
const val ASSET_NAME = "Wolfskin"
|
||||||
|
|
@ -123,6 +128,10 @@ class UpdateChecker
|
||||||
updateUrl: String,
|
updateUrl: String,
|
||||||
showNegativeToast: Boolean = false,
|
showNegativeToast: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||||
|
household.get().controller.check(manual = showNegativeToast)
|
||||||
|
return
|
||||||
|
}
|
||||||
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val now = Date()
|
val now = Date()
|
||||||
val lastUpdateCheckThreshold =
|
val lastUpdateCheckThreshold =
|
||||||
|
|
@ -181,6 +190,12 @@ class UpdateChecker
|
||||||
* so this searches the release list instead of fetching a tag.
|
* so this searches the release list instead of fetching a tag.
|
||||||
*/
|
*/
|
||||||
suspend fun getRelease(version: Version): Release? {
|
suspend fun getRelease(version: Version): Release? {
|
||||||
|
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||||
|
return withContext(WholphinDispatchers.IO) {
|
||||||
|
val releases = UpdateTransport().releasesForLatest(household.get().channelUrl())
|
||||||
|
findReleaseByVersion(releases, version)?.let { parseRelease(it, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -233,6 +248,9 @@ class UpdateChecker
|
||||||
release: Release,
|
release: Release,
|
||||||
callback: DownloadCallback,
|
callback: DownloadCallback,
|
||||||
) {
|
) {
|
||||||
|
check(context.packageName != UpdateChannel.PACKAGE) {
|
||||||
|
"Household updates require the verified update controller"
|
||||||
|
}
|
||||||
withContext(WholphinDispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
cleanup()
|
cleanup()
|
||||||
val request =
|
val request =
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
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.Qualifier
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -91,12 +94,14 @@ object AppModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient() =
|
fun okHttpClient(dns: PersistentDns) =
|
||||||
OkHttpClient
|
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
|
||||||
.Builder()
|
|
||||||
.apply {
|
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
|
||||||
// TODO user agent, timeouts, logging, etc
|
@Provides
|
||||||
}.build()
|
@Singleton
|
||||||
|
fun dns(@ApplicationContext context: Context): PersistentDns =
|
||||||
|
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import coil3.network.NetworkResponse
|
||||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import coil3.request.crossfade
|
import coil3.request.transitionFactory
|
||||||
|
import org.justinvisser.wolfskin.design.NetworkArtTransition
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -89,7 +90,7 @@ fun CoilConfig(
|
||||||
memoryCache(null)
|
memoryCache(null)
|
||||||
diskCache(null)
|
diskCache(null)
|
||||||
}
|
}
|
||||||
}.crossfade(false)
|
}.transitionFactory(NetworkArtTransition)
|
||||||
.logger(if (debugLogging) DebugLogger() else null)
|
.logger(if (debugLogging) DebugLogger() else null)
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,11 @@ class PlayExternalViewModel
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||||
val state = MutableStateFlow(PlayExternalState())
|
val state = MutableStateFlow(PlayExternalState())
|
||||||
|
private var watchScope: String? = null
|
||||||
|
|
||||||
fun init(destination: Destination) {
|
fun init(destination: Destination) {
|
||||||
Timber.v("init called: %s", destination)
|
Timber.v("init called: %s", destination)
|
||||||
|
watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||||
state.update { it.copy(loading = LoadingState.Loading) }
|
state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
|
@ -358,6 +360,7 @@ class PlayExternalViewModel
|
||||||
failed = false,
|
failed = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
|
|
|
||||||
|
|
@ -830,10 +830,12 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val playbackItemState = PlaybackItemState(playback)
|
val playbackItemState = PlaybackItemState(playback)
|
||||||
|
val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||||
val activityListener =
|
val activityListener =
|
||||||
TrackActivityPlaybackListener(
|
TrackActivityPlaybackListener(
|
||||||
api = api,
|
api = api,
|
||||||
player = player,
|
player = player,
|
||||||
|
onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) },
|
||||||
getState = { playbackItemState },
|
getState = { playbackItemState },
|
||||||
)
|
)
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -89,6 +90,7 @@ import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
@ -355,7 +357,9 @@ fun PreferencesContent(
|
||||||
AppPreference.Update -> {
|
AppPreference.Update -> {
|
||||||
ClickPreference(
|
ClickPreference(
|
||||||
title =
|
title =
|
||||||
if (release != null && updateAvailable) {
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
stringResource(R.string.check_for_updates)
|
||||||
|
} else if (release != null && updateAvailable) {
|
||||||
stringResource(R.string.install_update)
|
stringResource(R.string.install_update)
|
||||||
} else if (!preferences.autoCheckForUpdates && release == null) {
|
} else if (!preferences.autoCheckForUpdates && release == null) {
|
||||||
stringResource(R.string.check_for_updates)
|
stringResource(R.string.check_for_updates)
|
||||||
|
|
@ -364,7 +368,9 @@ fun PreferencesContent(
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (movementSounds) playOnClickSound(context)
|
if (movementSounds) playOnClickSound(context)
|
||||||
if (release != null && updateAvailable) {
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||||
|
} else if (release != null && updateAvailable) {
|
||||||
release?.let {
|
release?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.UpdateApp,
|
Destination.UpdateApp,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.DownloadCallback
|
import com.github.damontecres.wholphin.services.DownloadCallback
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
|
@ -73,6 +74,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import org.justinvisser.wolfskin.tv.update.ClassicUpdatePage
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateController
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -82,6 +86,7 @@ class UpdateViewModel
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
val updater: UpdateChecker,
|
val updater: UpdateChecker,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
val householdController: UpdateController,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
DownloadCallback {
|
DownloadCallback {
|
||||||
private val _state = MutableStateFlow(InstallUpdateState())
|
private val _state = MutableStateFlow(InstallUpdateState())
|
||||||
|
|
@ -93,6 +98,10 @@ class UpdateViewModel
|
||||||
val currentVersion = updater.getInstalledVersion()
|
val currentVersion = updater.getInstalledVersion()
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
householdController.check(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
||||||
|
|
@ -166,6 +175,10 @@ fun InstallUpdatePage(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: UpdateViewModel = hiltViewModel(),
|
viewModel: UpdateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
ClassicUpdatePage(viewModel.householdController, modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
OneTimeLaunchedEffect { viewModel.init() }
|
OneTimeLaunchedEffect { viewModel.init() }
|
||||||
|
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ class SwitchSeerrViewModel
|
||||||
_state.update { it.copy(loading = LoadingState.Success) }
|
_state.update { it.copy(loading = LoadingState.Success) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Nothing to check so set success
|
||||||
|
_state.update { it.copy(loading = LoadingState.Success) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||||
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
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 com.google.auto.service.AutoService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -73,10 +78,7 @@ class CrashReportSender : ReportSender {
|
||||||
|
|
||||||
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||||
(this as? MostRecentServer.ServerAndUser)?.let {
|
(this as? MostRecentServer.ServerAndUser)?.let {
|
||||||
val okHttpClient =
|
val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client()
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
|
||||||
.build()
|
|
||||||
val okHttpFactory = OkHttpFactory(okHttpClient)
|
val okHttpFactory = OkHttpFactory(okHttpClient)
|
||||||
val api =
|
val api =
|
||||||
createJellyfin {
|
createJellyfin {
|
||||||
|
|
@ -89,3 +91,11 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||||
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
||||||
api
|
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 com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
|
@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
class TrackActivityPlaybackListener(
|
class TrackActivityPlaybackListener(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
|
private val onStopped: () -> Unit = {},
|
||||||
private val getState: () -> PlaybackItemState?,
|
private val getState: () -> PlaybackItemState?,
|
||||||
) : Player.Listener {
|
) : Player.Listener {
|
||||||
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
||||||
|
private val reports = Mutex()
|
||||||
|
@Volatile private var released = false
|
||||||
private val task: TimerTask =
|
private val task: TimerTask =
|
||||||
object : TimerTask() {
|
object : TimerTask() {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
|
|
@ -70,19 +75,23 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
val delay = 5.seconds.inWholeMilliseconds
|
val delay = 5.seconds.inWholeMilliseconds
|
||||||
// Every x seconds, check if the video is playing
|
// Every x seconds, check if the video is playing
|
||||||
TIMER.schedule(task, delay, delay)
|
if (!released) {
|
||||||
initialized = true
|
TIMER.schedule(task, delay, delay)
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
// player.removeListener(this)
|
if (released) return
|
||||||
|
released = true
|
||||||
task.cancel()
|
task.cancel()
|
||||||
TIMER.purge()
|
TIMER.purge()
|
||||||
val position = player.currentPosition.milliseconds
|
val position = player.currentPosition.milliseconds
|
||||||
|
val stoppedState = getState.invoke()
|
||||||
launch("reportPlaybackStopped") {
|
launch("reportPlaybackStopped") {
|
||||||
getState.invoke()?.let { state ->
|
stoppedState?.let { state ->
|
||||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||||
if (position < Duration.ZERO) {
|
if (position < Duration.ZERO) {
|
||||||
Timber.w("Negative position when reporting playback stopped: %s", position)
|
Timber.w("Negative position when reporting playback stopped: %s", position)
|
||||||
|
|
@ -96,6 +105,7 @@ class TrackActivityPlaybackListener(
|
||||||
liveStreamId = state.liveStreamId,
|
liveStreamId = state.liveStreamId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
onStopped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +127,7 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
private fun saveActivity(position: Long) {
|
private fun saveActivity(position: Long) {
|
||||||
launch("saveActivity") {
|
launch("saveActivity") {
|
||||||
|
if (released) return@launch
|
||||||
getState.invoke()?.let { state ->
|
getState.invoke()?.let { state ->
|
||||||
val calcPosition =
|
val calcPosition =
|
||||||
withContext(WholphinDispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
|
|
@ -152,7 +163,7 @@ class TrackActivityPlaybackListener(
|
||||||
) {
|
) {
|
||||||
coroutineScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
try {
|
try {
|
||||||
block.invoke(this)
|
reports.withLock { block.invoke(this) }
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Exception during %s", name)
|
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.slot
|
||||||
import io.mockk.unmockkStatic
|
import io.mockk.unmockkStatic
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
@ -116,9 +118,6 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
|
||||||
// coEvery { serverRepository.restoreLastSession() } returns
|
|
||||||
// RestoredSession.Success(currentUser)
|
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
|
|
@ -132,7 +131,7 @@ class TestMainActivityViewModel {
|
||||||
destination as SetupDestination.AppContent
|
destination as SetupDestination.AppContent
|
||||||
Assert.assertEquals(currentUser, destination.current)
|
Assert.assertEquals(currentUser, destination.current)
|
||||||
}
|
}
|
||||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +149,7 @@ class TestMainActivityViewModel {
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
||||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
||||||
val args = mutableListOf<SetupDestination>()
|
val args = mutableListOf<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
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
|
@Test
|
||||||
fun `Test auto sign in, hot load, with protected profile`() =
|
fun `Test auto sign in, hot load, with protected profile`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|
@ -178,7 +191,7 @@ class TestMainActivityViewModel {
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
} returns null
|
} returns null
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
@ -204,7 +217,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
|
|
@ -212,7 +225,7 @@ class TestMainActivityViewModel {
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||||
val slot = slot<SetupDestination>()
|
val slot = slot<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
slot.captured.let { destination ->
|
slot.captured.let { destination ->
|
||||||
|
|
@ -232,11 +245,11 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||||
val slot = slot<SetupDestination>()
|
val slot = slot<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
slot.captured.let { destination ->
|
slot.captured.let { destination ->
|
||||||
|
|
@ -254,7 +267,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.Success(currentUser)
|
RestoredSession.Success(currentUser)
|
||||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||||
|
|
||||||
|
|
@ -271,7 +284,7 @@ class TestMainActivityViewModel {
|
||||||
// destination as SetupDestination.AppContent
|
// destination as SetupDestination.AppContent
|
||||||
// Assert.assertEquals(currentUser, destination.current)
|
// Assert.assertEquals(currentUser, destination.current)
|
||||||
// }
|
// }
|
||||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
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
|
import kotlin.io.path.readText
|
||||||
|
|
||||||
class TestUpdateChecker {
|
class TestUpdateChecker {
|
||||||
|
@Test
|
||||||
|
fun householdChannelMigrationDoesNotChangeCustomOrOldProductionFeeds() {
|
||||||
|
val channel = org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
|
Assert.assertEquals(channel.LATEST_URL, channel.migrateUrl(channel.PACKAGE, channel.LEGACY_URL))
|
||||||
|
Assert.assertEquals("https://custom.test/releases/latest", channel.migrateUrl(channel.PACKAGE, "https://custom.test/releases/latest"))
|
||||||
|
Assert.assertEquals(channel.LEGACY_URL, channel.migrateUrl("org.justinvisser.wolfskin.tv", channel.LEGACY_URL))
|
||||||
|
Assert.assertEquals(channel.LEGACY_URL, channel.defaultUrl("org.justinvisser.wolfskin.tv"))
|
||||||
|
Assert.assertEquals(channel.LATEST_URL, channel.defaultUrl(channel.PACKAGE))
|
||||||
|
}
|
||||||
|
|
||||||
lateinit var releaseJson: JsonObject
|
lateinit var releaseJson: JsonObject
|
||||||
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.test
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -18,6 +19,19 @@ class VersionCompareTests {
|
||||||
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
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
|
@Test
|
||||||
fun testIsAtLeast() {
|
fun testIsAtLeast() {
|
||||||
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
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.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@TestInstallIn(
|
@TestInstallIn(
|
||||||
components = [SingletonComponent::class],
|
components = [SingletonComponent::class],
|
||||||
replaces = [DeviceModule::class, AppModule::class],
|
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
|
||||||
)
|
)
|
||||||
object TestModule {
|
object TestModule {
|
||||||
val testDispatcher = StandardTestDispatcher()
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
|
||||||
|
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun deviceInfo(
|
fun deviceInfo(
|
||||||
|
|
@ -91,12 +97,11 @@ object TestModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient() =
|
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
@Provides
|
||||||
.apply {
|
@Singleton
|
||||||
// TODO user agent, timeouts, logging, etc
|
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
|
||||||
}.build()
|
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ class PlayExternalViewModelTest {
|
||||||
every { api.subtitleApi } returns subtitleApi
|
every { api.subtitleApi } returns subtitleApi
|
||||||
every { api.playStateApi } returns playStateApi
|
every { api.playStateApi } returns playStateApi
|
||||||
every { serverRepository.currentUser } returns null
|
every { serverRepository.currentUser } returns null
|
||||||
|
every { serverRepository.currentServer } returns null
|
||||||
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
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.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class AnimeAvailabilityTest {
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||||
|
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) {
|
||||||
|
for (count in listOf(0, 1)) FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token)
|
||||||
|
conductarr.select("${server.id}:${user.id}", token, fake.url)
|
||||||
|
conductarr.authenticate()
|
||||||
|
val identity = conductarr.identity.value!!
|
||||||
|
val audiences = identity.provider.audiences()!!.filter {
|
||||||
|
it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty())
|
||||||
|
}
|
||||||
|
val names = audiences.flatMap { it.libraries.orEmpty() }.distinct()
|
||||||
|
assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty())
|
||||||
|
val views = mockk<UserViewsApi>()
|
||||||
|
val items = mockk<ItemsApi>()
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
every { api.userViewsApi } returns views
|
||||||
|
every { api.itemsApi } returns items
|
||||||
|
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map {
|
||||||
|
BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
|
||||||
|
})
|
||||||
|
val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) }
|
||||||
|
coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles)
|
||||||
|
val libraries = LibraryReadFacade(api)
|
||||||
|
val session = WolfskinSession().apply {
|
||||||
|
state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply {
|
||||||
|
pickerVisible = false
|
||||||
|
focusNav(tabs.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), 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)
|
||||||
|
assertEquals("The avatar must remain focused when Anime appears or disappears",
|
||||||
|
session.state!!.tabs.size, session.state!!.navIndex)
|
||||||
|
val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }),
|
||||||
|
destinationTotals = mapOf("anime" to count))
|
||||||
|
assertEquals("An empty Anime audience must not create a Home shelf", count > 0,
|
||||||
|
buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" })
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
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.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class AnimeRootRemovalTest {
|
||||||
|
private var previousFixtures = false
|
||||||
|
private var previousUrl = ""
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||||
|
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
previousUrl = ConductarrPreference.baseUrl
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking {
|
||||||
|
val title = Destination.Wolfskin("title/tv%3A123")
|
||||||
|
val person = Destination.Wolfskin("person/456")
|
||||||
|
val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true,
|
||||||
|
listOf(title) to false, listOf(title, person) to false)
|
||||||
|
for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns null
|
||||||
|
var hasTitles = true
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
|
coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles }
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), 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()
|
||||||
|
val shell = requireNotNull(session.state)
|
||||||
|
assertTrue(shell.person.hasAnime)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusNav(shell.tabs.indexOf("anime"))
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList())
|
||||||
|
for (page in pushed) {
|
||||||
|
navigation.navigateTo(page)
|
||||||
|
shell.enterDestination(page.route)
|
||||||
|
}
|
||||||
|
if (avatarFocused) shell.focusNav(shell.tabs.size)
|
||||||
|
|
||||||
|
hasTitles = false
|
||||||
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs)
|
||||||
|
assertEquals("Home must replace the missing root, not the current pushed page",
|
||||||
|
listOf(Destination.Home()) + pushed, navigation.backStack.toList())
|
||||||
|
assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route)
|
||||||
|
assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab)
|
||||||
|
if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex)
|
||||||
|
|
||||||
|
shell.key(ShellKey.Back)
|
||||||
|
if (pushed.isEmpty()) {
|
||||||
|
assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
if (!avatarFocused) assertEquals(0, shell.navIndex)
|
||||||
|
} else {
|
||||||
|
assertEquals("Back pops only the current page and retains the replacement Home root",
|
||||||
|
listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList())
|
||||||
|
}
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
val client = session.identity.value!!.client
|
val client = session.identity.value!!.client
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
try {
|
try {
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
assertNull(libraries.audiences)
|
assertNull(libraries.audiences)
|
||||||
|
|
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
session.authenticate()
|
session.authenticate()
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
try {
|
try {
|
||||||
val identity = session.identity.value!!
|
val identity = session.identity.value!!
|
||||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest {
|
||||||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val home = mockk<HomeReadFacade>()
|
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)))
|
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)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ class ConductarrProjectionTest {
|
||||||
assertEquals(ComingInStage.Downloading, request.stage)
|
assertEquals(ComingInStage.Downloading, request.stage)
|
||||||
assertEquals("Mindel", request.requesterName)
|
assertEquals("Mindel", request.requesterName)
|
||||||
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
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() }
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest {
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
assertNotNull(current.title)
|
assertNotNull(current.title)
|
||||||
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
||||||
assertEquals("discover-title/${current.title!!.key}", shell.route)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) } }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
// 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.junit4.createAndroidComposeRule
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** No drawn screen may prewarm memory before the production Discover observer starts. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class DiscoverSnapshotReadTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun signedInHostObservesFreshShellThroughTheRealAsyncProvider() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val identity = requireNotNull(session.identity.value)
|
||||||
|
assertTrue(identity.signedIn)
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared")
|
||||||
|
lateinit var current: DiscoverState
|
||||||
|
rule.setContent {
|
||||||
|
current = rememberDiscoverState(shell, shell::enterDestination, {}, identity = identity, fixtures = false,
|
||||||
|
loadFixture = { error("Production observer must not load fixtures") })
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
current.snapshot.status == DiscoverStatus.Ready
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertTrue(fake.calls.any { it.url.encodedPath.endsWith("/feed") })
|
||||||
|
assertEquals(0, current.focusedRow)
|
||||||
|
assertEquals(0, current.column)
|
||||||
|
current.focus(current.firstShelf)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertNotNull(current.focusedLane)
|
||||||
|
assertEquals(current.firstShelf, current.focusedRow)
|
||||||
|
assertEquals(0, current.column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
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.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 com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class DiscoverTitleTimingTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.localArt = true
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val identity = session.identity.value!!
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
|
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
||||||
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true))
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
lateinit var discover: DiscoverState
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
try {
|
||||||
|
rule.setContent {
|
||||||
|
val route = (navigation.backStack.last() as Destination.Wolfskin).route
|
||||||
|
LaunchedEffect(route) { shell.enterDestination(route) }
|
||||||
|
discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false)
|
||||||
|
WolfskinTheme {
|
||||||
|
if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
|
||||||
|
else ShellSurface(shell) { DiscoverScreen(discover, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
|
||||||
|
rule.runOnIdle {
|
||||||
|
fake.beforeResponse = { if (it.endsWith("/cast")) release.await() }
|
||||||
|
discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning"))
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/"))
|
||||||
|
rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } }
|
||||||
|
rule.runOnIdle { release.countDown() }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
assertEquals("cast", model.title!!.row)
|
||||||
|
rule.onNodeWithText("Sam Rockwell").assertIsDisplayed()
|
||||||
|
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
assertEquals("related", model.title!!.row)
|
||||||
|
rule.onNodeWithText("More like this").assertIsDisplayed()
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertNull(discover.title)
|
||||||
|
assertEquals("discover", shell.route)
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import mockwebserver3.RecordedRequest
|
||||||
|
|
||||||
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
||||||
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
|
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. */
|
/** A loopback server supplies wire JSON, never app projections or live service state. */
|
||||||
internal class FakeConductarr : Closeable {
|
internal class FakeConductarr : Closeable {
|
||||||
|
|
@ -26,6 +27,10 @@ internal class FakeConductarr : Closeable {
|
||||||
@Volatile var emptyAudiences = false
|
@Volatile var emptyAudiences = false
|
||||||
@Volatile var seasonTwoScore = 8.1
|
@Volatile var seasonTwoScore = 8.1
|
||||||
@Volatile var localArt = false
|
@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(
|
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||||
|
|
||||||
|
|
@ -37,6 +42,7 @@ internal class FakeConductarr : Closeable {
|
||||||
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
||||||
.body(okio.Buffer().write(imageBytes)).build()
|
.body(okio.Buffer().write(imageBytes)).build()
|
||||||
val path = request.url.encodedPath
|
val path = request.url.encodedPath
|
||||||
|
beforeResponse?.invoke(path)
|
||||||
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
|
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
|
||||||
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
||||||
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
||||||
|
|
@ -64,6 +70,9 @@ internal class FakeConductarr : Closeable {
|
||||||
}
|
}
|
||||||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||||
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
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 (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||||
var body = wire.getValue(key).jsonObject
|
var body = wire.getValue(key).jsonObject
|
||||||
|
|
@ -71,6 +80,9 @@ internal class FakeConductarr : Closeable {
|
||||||
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
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")))
|
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") {
|
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
|
||||||
body = JsonObject(body + ("media_type" to JsonPrimitive("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")))))))
|
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")))))))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.ui.successQueryResult
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.*
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.After
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class FirstSignInNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private lateinit var previousUrl: String
|
||||||
|
private var previousFixtures = false
|
||||||
|
private var previousScreens = true
|
||||||
|
|
||||||
|
@Before fun captureGlobalPreferences() {
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
previousUrl = ConductarrPreference.baseUrl
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
previousScreens = WolfskinScreenPreference.enabled
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun restoreGlobalPreferences() {
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
WolfskinScreenPreference.enabled = previousScreens
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() {
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}"""
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
val views = mockk<UserViewsApi>()
|
||||||
|
val items = mockk<ItemsApi>()
|
||||||
|
every { api.userViewsApi } returns views
|
||||||
|
every { api.itemsApi } returns items
|
||||||
|
val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
|
||||||
|
val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
|
||||||
|
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.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), 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 {
|
||||||
|
rule.setContent {
|
||||||
|
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model,
|
||||||
|
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
|
||||||
|
}
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
conductarr.identity.value?.signedIn == true && model.libraries.audiences != null
|
||||||
|
}
|
||||||
|
rule.onNodeWithText("Mindel").assertIsDisplayed()
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
|
||||||
|
assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible)
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
|
||||||
|
pressKey(Key.DirectionUp)
|
||||||
|
pressKey(Key.DirectionRight)
|
||||||
|
pressKey(Key.DirectionRight)
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
|
rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Desperate Housewives").assertDoesNotExist()
|
||||||
|
assertEquals(6, session.state!!.view.library!!.source.size)
|
||||||
|
assertFalse(session.state!!.person.hasAnime)
|
||||||
|
rule.onNodeWithText("Shows").performClick()
|
||||||
|
rule.waitForIdle()
|
||||||
|
try {
|
||||||
|
rule.waitUntil(timeoutMillis = 15_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
rule.onAllNodes(hasTestTag("library-grid") and
|
||||||
|
hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
} catch (timeout: ComposeTimeoutException) {
|
||||||
|
val shell = session.state!!
|
||||||
|
throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " +
|
||||||
|
"movies=${shell.rememberedView("movies").library?.source?.size}, " +
|
||||||
|
"shows=${shell.rememberedView("shows").library?.source?.size}/" +
|
||||||
|
"${shell.rememberedView("shows").library?.status}", timeout)
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
|
assertEquals(29, session.state!!.view.library!!.source.size)
|
||||||
|
rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Home").performClick()
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
|
||||||
|
assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible)
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,11 +43,13 @@ class HomeRecoveryTest {
|
||||||
val started = CompletableDeferred<Unit>()
|
val started = CompletableDeferred<Unit>()
|
||||||
val response = CompletableDeferred<HomeLibrary>()
|
val response = CompletableDeferred<HomeLibrary>()
|
||||||
val facade = mockk<HomeReadFacade>()
|
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 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) }
|
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||||
started.await()
|
started.await()
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
|
assertFalse(model.snapshot.rows.first().watchReady)
|
||||||
response.completeExceptionally(IOException("fixture offline"))
|
response.completeExceptionally(IOException("fixture offline"))
|
||||||
refresh.await()
|
refresh.await()
|
||||||
assertFalse(model.snapshot.jellyfinAvailable)
|
assertFalse(model.snapshot.jellyfinAvailable)
|
||||||
|
|
@ -62,8 +64,9 @@ class HomeRecoveryTest {
|
||||||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||||
val startedA = CompletableDeferred<Unit>()
|
val startedA = CompletableDeferred<Unit>()
|
||||||
val responseA = CompletableDeferred<HomeLibrary>()
|
val responseA = CompletableDeferred<HomeLibrary>()
|
||||||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
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 model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||||
startedA.await()
|
startedA.await()
|
||||||
|
|
@ -80,7 +83,8 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
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>()
|
val cache = mockk<HomeLibraryCache>()
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||||
|
|
@ -93,7 +97,8 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
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)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class LibraryReadFacadeTest {
|
||||||
val offsets = mutableListOf<Int>()
|
val offsets = mutableListOf<Int>()
|
||||||
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
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 {
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
|
||||||
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
||||||
assertEquals(200, offsets.last())
|
assertEquals(200, offsets.last())
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest {
|
||||||
assertFalse(reads.last().unreachable)
|
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 {
|
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||||
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||||
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toPixelMap
|
||||||
|
import androidx.compose.ui.test.captureToImage
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Scrolled cards must be occluded across the entire persistent navigation band. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class NavBandOcclusionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun scrolledRowsNeverShowThroughTheNavBand() {
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||||
|
LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) {
|
||||||
|
items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) }
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val pixels = rule.onRoot().captureToImage().toPixelMap()
|
||||||
|
val background = WolfskinTokens.Color.bg
|
||||||
|
for (y in listOf(5, 50, 135)) {
|
||||||
|
val actual = pixels[20, y]
|
||||||
|
assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f)
|
||||||
|
assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f)
|
||||||
|
assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||||
val title = snapshot.rows.first().cards.first()
|
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 {
|
rule.setContent { WolfskinTheme {
|
||||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||||
|
|
@ -80,14 +80,16 @@ class PlayerHandoffTest {
|
||||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
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
|
val view = shell.view
|
||||||
view.cards["hero"] = 0
|
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 }
|
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||||
val destination = navigation.backStack.last() as Destination.Playback
|
val destination = navigation.backStack.last() as Destination.Playback
|
||||||
assertEquals(id, destination.itemId)
|
assertEquals(id, destination.itemId)
|
||||||
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
assertEquals(1_136_274L, destination.positionMs)
|
||||||
assertTrue(destination.fullScreen)
|
assertTrue(destination.fullScreen)
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
|
|
@ -99,11 +101,11 @@ class PlayerHandoffTest {
|
||||||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val otherUser = user.copy(name = "Justin")
|
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 }
|
awaitModel { model.toast != null }
|
||||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
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) {
|
private fun awaitModel(condition: () -> Boolean) {
|
||||||
rule.waitUntil(5000) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
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.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class ShellStartupTimingTest {
|
||||||
|
@Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
|
||||||
|
@After fun resetMain() { Dispatchers.resetMain() }
|
||||||
|
|
||||||
|
@Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val previous = ConductarrPreference.baseUrl
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
|
conductarr.authenticate()
|
||||||
|
val activityEntered = CompletableDeferred<Unit>()
|
||||||
|
val releaseActivity = java.util.concurrent.CountDownLatch(1)
|
||||||
|
fake.beforeResponse = {
|
||||||
|
if (it == "/api/v1/activity") {
|
||||||
|
activityEntered.complete(Unit)
|
||||||
|
releaseActivity.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 { 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
val load = launch { model.load(server, user) }
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
for (route in listOf("movies", "shows", "discover")) {
|
||||||
|
shell.focusNav(shell.tabs.indexOf(route))
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
verify { navigation.replace(Destination.Wolfskin(route)) }
|
||||||
|
assertEquals(route, shell.route)
|
||||||
|
}
|
||||||
|
savedPeople.complete(Unit)
|
||||||
|
load.join()
|
||||||
|
assertSame(shell, session.state)
|
||||||
|
assertEquals("discover", shell.route)
|
||||||
|
assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted)
|
||||||
|
} finally {
|
||||||
|
releaseActivity.countDown()
|
||||||
|
activity.cancelAndJoin()
|
||||||
|
savedPeople.complete(Unit)
|
||||||
|
load.cancelAndJoin()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConductarrPreference.updateBaseUrl(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val previous = ConductarrPreference.baseUrl
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns
|
||||||
|
com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel))
|
||||||
|
val switched = CompletableDeferred<Unit>()
|
||||||
|
coEvery { repository.changeUser(server, mindel) } coAnswers {
|
||||||
|
switched.complete(Unit)
|
||||||
|
com.github.damontecres.wholphin.data.CurrentUser(server, mindel)
|
||||||
|
}
|
||||||
|
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
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), 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)
|
||||||
|
fake.beforeResponse = {
|
||||||
|
if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) {
|
||||||
|
pending.complete(Unit)
|
||||||
|
release.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
model.load(server, shared)
|
||||||
|
withTimeout(5_000) { pending.await() }
|
||||||
|
val shell = requireNotNull(session.state)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.enterDestination("discover")
|
||||||
|
shell.focusNav(shell.tabs.size)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusPerson(1)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
verify { navigation.replace(Destination.Home()) }
|
||||||
|
withTimeout(5_000) { switched.await() }
|
||||||
|
model.load(server, mindel)
|
||||||
|
requireNotNull(model.onForeground(server, mindel)).join()
|
||||||
|
release.countDown()
|
||||||
|
assertSame(shell, session.state)
|
||||||
|
assertEquals("mindel", model.signedInPerson)
|
||||||
|
assertEquals(mindel.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
assertEquals("Mindel", shell.person.name)
|
||||||
|
assertEquals("mindel", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConductarrPreference.updateBaseUrl(previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
|
@ -35,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -152,7 +154,10 @@ class TitleAsyncFocusTest {
|
||||||
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
||||||
rule.runOnIdle { viewModel.title!!.toast = null }
|
rule.runOnIdle { viewModel.title!!.toast = null }
|
||||||
press(Key.DirectionCenter)
|
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()
|
rule.waitForIdle()
|
||||||
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
||||||
assertEquals("20:42", shell.navSavedAt)
|
assertEquals("20:42", shell.navSavedAt)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
||||||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||||
data.episodes.forEach { item ->
|
data.episodes.forEach { item ->
|
||||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 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) ->
|
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||||
|
|
@ -215,4 +215,5 @@ class TitleCaptureTest {
|
||||||
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
||||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||||
|
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performMouseInput
|
import androidx.compose.ui.test.performMouseInput
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.hasClickAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
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 press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||||
private fun replay(end: String): Int {
|
private fun replay(end: String): Int {
|
||||||
val path = paths.single { it.getString("end") == end }
|
val path = paths.single { it.getString("end") == end }
|
||||||
|
|
@ -312,11 +336,6 @@ class TitleKeysTest {
|
||||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
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() {
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
render(transform = { data ->
|
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.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
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.Assert.*
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
|
@ -81,6 +83,7 @@ class TitleReadFacadeTest {
|
||||||
every { repository.currentServer } returns null
|
every { repository.currentServer } returns null
|
||||||
every { repository.currentUser } returns null
|
every { repository.currentUser } returns null
|
||||||
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
||||||
|
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList())
|
||||||
val context = mockk<Context> { every { filesDir } returns directory.root }
|
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +106,31 @@ class TitleReadFacadeTest {
|
||||||
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
@Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
|
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
|
||||||
|
listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42"))))
|
||||||
|
val page = facade.read(firstId, null, preferences)
|
||||||
|
assertEquals(42L, page.item.tmdbId)
|
||||||
|
assertEquals(firstId.toString(), page.watchTarget.id)
|
||||||
|
assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id)
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}
|
||||||
|
ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId),
|
||||||
|
org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) }
|
||||||
|
assertEquals(listOf("header", "cast", "related"), screen.rows)
|
||||||
|
assertEquals("Sam Rockwell", screen.data.cast.single().name)
|
||||||
|
assertEquals(firstId.toString(), screen.target?.id)
|
||||||
|
assertTrue(screen.data.item.held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||||
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
|
||||||
facade.read(firstId, null, preferences)
|
facade.read(firstId, null, preferences)
|
||||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||||
val saved = facade.readSaved(firstId, preferences)!!
|
val saved = facade.readSaved(firstId, preferences)!!
|
||||||
|
|
@ -115,6 +141,7 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||||
assertTrue(saved.item.ratings.isEmpty())
|
assertTrue(saved.item.ratings.isEmpty())
|
||||||
assertEquals(firstId.toString(), saved.watchTarget.id)
|
assertEquals(firstId.toString(), saved.watchTarget.id)
|
||||||
|
assertFalse(screen.options.any { it.id == "play" || it.id == "restart" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
||||||
|
|
@ -194,6 +221,43 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
|
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 {
|
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
coEvery { playback.savePlayVersion(any(), any()) } returns null
|
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.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -16,7 +17,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Singleton
|
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.FileCacheStore
|
||||||
|
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||||
|
|
||||||
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
||||||
object ConductarrPreference {
|
object ConductarrPreference {
|
||||||
|
|
@ -50,6 +55,15 @@ object ConductarrPreference {
|
||||||
object ConductarrModule {
|
object ConductarrModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun session(@ApplicationContext context: Context): ConductarrSession =
|
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession =
|
||||||
ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr")))
|
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(),
|
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 {
|
internal fun RequestProgress.comingIn(): ComingInRequest {
|
||||||
val stagesByName = stages.orEmpty().associateBy { it.name }
|
val stagesByName = stages.orEmpty().associateBy { it.name }
|
||||||
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
||||||
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
||||||
|
val playback = stagesByName[ReadinessStage.Name.PLAYBACK]
|
||||||
val stage = when {
|
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
|
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
|
||||||
awaitingApproval == true -> ComingInStage.Waiting
|
awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
|
||||||
visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
|
||||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading
|
visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||||
acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching
|
acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
|
||||||
stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking
|
acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
|
||||||
else -> ComingInStage.Waiting
|
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,
|
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 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 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 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 candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
|
||||||
val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate ->
|
val picks = candidates.map { it.discoverTitle(genres) }
|
||||||
candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } }
|
val seeds = candidates.flatMapIndexed { index, candidate ->
|
||||||
}.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) }
|
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 {
|
val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
|
||||||
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
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.io.Closeable
|
||||||
import java.security.MessageDigest
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.justinvisser.wolfskin.conductarr.*
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
import org.justinvisser.wolfskin.conductarr.model.Operator
|
import org.justinvisser.wolfskin.conductarr.model.Operator
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
||||||
data class ConductarrIdentity(
|
data class ConductarrIdentity(
|
||||||
|
|
@ -21,53 +26,86 @@ data class ConductarrIdentity(
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
/** 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)
|
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||||
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||||
private var selection: ByteArray? = null
|
private var selection: ByteArray? = null
|
||||||
@Volatile private var generation = 0L
|
@Volatile private var generation = 0L
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
||||||
val fingerprint = MessageDigest.getInstance("SHA-256")
|
val fingerprint = MessageDigest.getInstance("SHA-256")
|
||||||
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
||||||
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
val previous: ConductarrClient?
|
||||||
generation++
|
val currentGeneration: Long
|
||||||
val currentGeneration = generation
|
synchronized(this) {
|
||||||
mutableIdentity.value?.client?.close()
|
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||||
mutableIdentity.value = null
|
generation++
|
||||||
selection = fingerprint
|
currentGeneration = generation
|
||||||
if (token.isNullOrBlank()) return
|
previous = mutableIdentity.value?.client
|
||||||
val client = try {
|
mutableIdentity.value = null
|
||||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
selection = fingerprint
|
||||||
if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) {
|
}
|
||||||
mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) }
|
try {
|
||||||
}
|
val client = if (token.isNullOrBlank()) null else try {
|
||||||
})
|
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), dns = dns, onEvent = { event ->
|
||||||
} catch (_: IllegalArgumentException) { return }
|
if (event == ClientEvent.NEEDS_SIGN_IN) {
|
||||||
mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client))
|
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. */
|
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
|
||||||
suspend fun authenticate() {
|
suspend fun authenticate() {
|
||||||
val selected = mutableIdentity.value ?: return
|
val selected = mutableIdentity.value ?: return
|
||||||
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
||||||
if (mutableIdentity.value?.client !== selected.client) return@collect
|
|
||||||
when (result) {
|
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 {
|
is ReadResult.Failure -> mutableIdentity.update {
|
||||||
if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error)
|
if (it?.client !== selected.client) it
|
||||||
else it?.copy(error = result.error)
|
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() {
|
override fun close() {
|
||||||
generation++
|
generation++
|
||||||
mutableIdentity.value?.client?.close()
|
val previous = mutableIdentity.value?.client
|
||||||
mutableIdentity.value = null
|
mutableIdentity.value = null
|
||||||
selection = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,18 +95,6 @@ internal fun rememberDiscoverState(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(state.title?.key, state) {
|
|
||||||
val title = state.title ?: return@LaunchedEffect
|
|
||||||
if (!fixtures && identity?.signedIn == true) {
|
|
||||||
val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV
|
|
||||||
identity.provider.detail(title.tmdb, media).collect { result ->
|
|
||||||
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
|
|
||||||
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
|
|
||||||
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
|
|
||||||
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val result = if (fixtures) fixtureState ?: state else state
|
val result = if (fixtures) fixtureState ?: state else state
|
||||||
LaunchedEffect(shell.route, result) {
|
LaunchedEffect(shell.route, result) {
|
||||||
result.enterRoute(shell.route)
|
result.enterRoute(shell.route)
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||||
import org.justinvisser.wolfskin.design.*
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
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) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||||
catalogueJob?.cancel()
|
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) {
|
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)
|
loadLibrary(server, user, person, retry)
|
||||||
}
|
}
|
||||||
|
|
@ -72,11 +76,12 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
toast = null
|
toast = null
|
||||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
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
|
if (revision != loadRevision) return
|
||||||
var libraryCopy = saved?.first ?: HomeLibrary()
|
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||||
var libraryAvailable = true
|
var libraryAvailable = true
|
||||||
var librarySavedAt: String? = null
|
var librarySavedAt: String? = null
|
||||||
|
var watchReady = false
|
||||||
val identity = conductarr.identity.value
|
val identity = conductarr.identity.value
|
||||||
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||||
fun publish() {
|
fun publish() {
|
||||||
|
|
@ -87,8 +92,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||||
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||||
}
|
}
|
||||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
|
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
||||||
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
||||||
|
}, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||||
}
|
}
|
||||||
catalogueSnapshot = catalogueCopy
|
catalogueSnapshot = catalogueCopy
|
||||||
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
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)
|
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
suspend fun save() {
|
||||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
val library = libraryCopy
|
||||||
val savedAt = System.currentTimeMillis()
|
withContext(WholphinDispatchers.IO) {
|
||||||
libraryCopy = library
|
try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ }
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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?) {
|
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.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.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||||
is HomeAction.Play -> viewModelScope.launch {
|
is HomeAction.Play -> viewModelScope.launch {
|
||||||
|
val personKey = shell.person.key
|
||||||
val selectedId = shell.person.jellyfinUserId
|
val selectedId = shell.person.jellyfinUserId
|
||||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
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)
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
||||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
|
|
@ -202,7 +232,8 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||||
val identity by model.conductarrState.collectAsState()
|
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")
|
val view = shell.rememberedView("home")
|
||||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
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()
|
refresh()
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
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.TitleState
|
||||||
import org.justinvisser.wolfskin.design.TitleSurface
|
import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -79,6 +80,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private val saveMutex = Mutex()
|
private val saveMutex = Mutex()
|
||||||
private var performerPage = 1
|
private var performerPage = 1
|
||||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
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 openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
fun popRoute() = navigation.goBack()
|
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. */
|
/** 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) {
|
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||||
|
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
||||||
|
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
|
route = destination
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
person = null
|
person = null
|
||||||
|
|
@ -132,7 +144,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
if (title !== current) return
|
if (title !== current) return
|
||||||
if (heldId != null) {
|
if (heldId != null) {
|
||||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
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)
|
current.update(held)
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
|
@ -210,7 +222,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
internal suspend fun retrySignIn() {
|
internal suspend fun retrySignIn() {
|
||||||
val user = repository.currentUser ?: return
|
val user = repository.currentUser ?: return
|
||||||
val server = repository.currentServer ?: 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()
|
conductarr.authenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,13 +235,21 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
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.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||||
is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
is TitleAction.OpenTitle -> {
|
||||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||||
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
}) } ?: Destination.Wolfskin(titleRoute(action.item.id))
|
||||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
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."
|
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.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||||
is TitleAction.Season -> {
|
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 {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||||
facade.preparePlayback(id)
|
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||||
navigation.navigateTo(Destination.Playback(id, position))
|
if (title !== current || watchScope != playbackScope) return@launch
|
||||||
|
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||||
|
|
@ -306,7 +331,8 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
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()
|
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 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
|
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||||
LaunchedEffect(title?.data?.status) {
|
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.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -60,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
|
||||||
class WolfskinSession @Inject constructor() {
|
class WolfskinSession @Inject constructor() {
|
||||||
var state by mutableStateOf<ShellState?>(null)
|
var state by mutableStateOf<ShellState?>(null)
|
||||||
var serverId: String? = null
|
var serverId: String? = null
|
||||||
|
internal var fixtureMode: Boolean? = null
|
||||||
var onAction: (ShellAction) -> Unit = {}
|
var onAction: (ShellAction) -> Unit = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val seeAll: WolfskinSeeAllLists,
|
val seeAll: WolfskinSeeAllLists,
|
||||||
val conductarr: ConductarrSession,
|
val conductarr: ConductarrSession,
|
||||||
artwork: WolfskinArtwork,
|
artwork: WolfskinArtwork,
|
||||||
|
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
init { artwork.configure(context) }
|
init { artwork.configure(context) }
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||||
|
|
@ -85,24 +90,29 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
var signedInPerson by mutableStateOf("")
|
var signedInPerson by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
private var identityJob: kotlinx.coroutines.Job? = null
|
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) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||||
WolfskinScreenPreference.initialize(context)
|
withContext(Dispatchers.IO) {
|
||||||
ConductarrPreference.initialize(context)
|
WolfskinScreenPreference.initialize(context)
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
}
|
||||||
identityJob?.cancel()
|
identityJob?.cancel()
|
||||||
libraries.audiences = null
|
libraries.audiences = null
|
||||||
if (ConductarrPreference.fixtures) conductarr.close()
|
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()
|
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
||||||
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
|
|
||||||
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
|
||||||
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
|
|
||||||
session.serverId = server.id.toString()
|
|
||||||
fixtureMode = ConductarrPreference.fixtures
|
|
||||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
|
||||||
}
|
|
||||||
session.onAction = { action -> handleAction(action, server, user) }
|
session.onAction = { action -> handleAction(action, server, user) }
|
||||||
|
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
|
||||||
|
session.serverId = server.id.toString()
|
||||||
|
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() }
|
||||||
|
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
||||||
|
session.state?.updatePeople(savedPeople)
|
||||||
|
}
|
||||||
identityJob = viewModelScope.launch {
|
identityJob = viewModelScope.launch {
|
||||||
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||||
}
|
}
|
||||||
|
|
@ -123,12 +133,19 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
||||||
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
|
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
|
||||||
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
|
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
|
||||||
|
val hasAnime = try {
|
||||||
|
libraries.hasAnimeTitles(user.id)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false
|
||||||
|
}
|
||||||
|
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
||||||
val updated = session.state?.people.orEmpty().map { person ->
|
val updated = session.state?.people.orEmpty().map { person ->
|
||||||
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
||||||
val current = person.jellyfinUserId == user.id.toString()
|
val current = person.jellyfinUserId == user.id.toString()
|
||||||
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
||||||
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
|
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
|
||||||
hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime)
|
hasAnime = if (current) hasAnime else person.hasAnime)
|
||||||
}
|
}
|
||||||
session.state?.updatePeople(updated)
|
session.state?.updatePeople(updated)
|
||||||
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
|
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
|
||||||
|
|
@ -142,8 +159,10 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
withContext(Dispatchers.IO) {
|
||||||
retry = conductarr.identity.value?.signedIn != true)
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||||
|
retry = conductarr.identity.value?.signedIn != true)
|
||||||
|
}
|
||||||
loadHousehold(user)
|
loadHousehold(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,10 +193,17 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route))
|
is ShellAction.Destination -> {
|
||||||
|
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
||||||
|
when {
|
||||||
|
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()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
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.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||||
|
|
@ -187,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())
|
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()
|
conductarr.close()
|
||||||
libraries.audiences = null
|
libraries.audiences = null
|
||||||
viewModelScope.launch {
|
personSwitchJob = viewModelScope.launch {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
personSwitchMutex.withLock {
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
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 = ""
|
if (saved == null || saved.isProtected) return@launch
|
||||||
return@launch
|
try {
|
||||||
}
|
val current = repository.changeUser(server, saved)
|
||||||
try {
|
coroutineContext.ensureActive()
|
||||||
val current = repository.changeUser(server, saved)
|
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
} catch (exception: Exception) {
|
||||||
} catch (exception: Exception) {
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
coroutineContext.ensureActive()
|
||||||
signedInPerson = ""
|
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,8 +259,11 @@ fun WolfskinApp(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
||||||
|
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
|
||||||
|
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
||||||
) {
|
) {
|
||||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
|
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
||||||
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
|
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
|
||||||
LaunchedEffect(identity?.signedIn, identity?.person) {
|
LaunchedEffect(identity?.signedIn, identity?.person) {
|
||||||
|
|
@ -273,18 +305,30 @@ fun WolfskinApp(
|
||||||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
NavEntry(
|
||||||
|
destination,
|
||||||
|
contentKey = "${destination}_${server.id}_${user.id}",
|
||||||
|
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
|
||||||
|
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
|
||||||
|
} else emptyMap(),
|
||||||
|
) {
|
||||||
|
// Cached entries must not retain the launch shell after sign-in completes.
|
||||||
|
val state = LocalShellState.current
|
||||||
|
val discover = LocalDiscoverState.current
|
||||||
val route = when (destination) {
|
val route = when (destination) {
|
||||||
is Destination.Home -> "home"
|
is Destination.Home -> "home"
|
||||||
is Destination.Wolfskin -> destination.route
|
is Destination.Wolfskin -> destination.route
|
||||||
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
||||||
else -> "pushed"
|
else -> "pushed"
|
||||||
}
|
}
|
||||||
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
|
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
||||||
|
LaunchedEffect(destination, state, isCurrent) {
|
||||||
|
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
||||||
|
}
|
||||||
if (destination.fullScreen) {
|
if (destination.fullScreen) {
|
||||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||||
} else {
|
} else {
|
||||||
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||||
|
|
@ -293,11 +337,15 @@ fun WolfskinApp(
|
||||||
} else TitleDestination(route, state, preferences)
|
} else TitleDestination(route, state, preferences)
|
||||||
} else {
|
} else {
|
||||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||||
|
settingsInstalledVersion = installedVersion.versionName,
|
||||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||||
when {
|
when {
|
||||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
||||||
|
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
|
||||||
|
onBack = { state.navFocused = false; navigationManager.goBack() },
|
||||||
|
onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier)
|
||||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
||||||
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
||||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ fun WolfskinLibraryContent(
|
||||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
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) {
|
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(
|
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(),
|
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||||
|
|
@ -72,7 +72,8 @@ fun WolfskinLibraryContent(
|
||||||
ShellPlaceholder("See all list unavailable", modifier)
|
ShellPlaceholder("See all list unavailable", modifier)
|
||||||
return
|
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)) {
|
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||||
val wasLoading = grid.status == LibraryStatus.Loading
|
val wasLoading = grid.status == LibraryStatus.Loading
|
||||||
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
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.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
||||||
grid.status = LibraryStatus.Ready
|
grid.status = LibraryStatus.Ready
|
||||||
} else {
|
} 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
|
val focusedId = grid.focusedTitle?.id
|
||||||
grid.source = snapshot.items
|
grid.source = snapshot.items
|
||||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
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. */
|
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
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>? {
|
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||||
val entry = store.read(scope, KEY) ?: return null
|
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. */
|
/** 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) {
|
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||||
val audiences get() = libraries.audiences
|
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 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 nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||||
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
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 resume = resumeItems.map { title(it, series) }
|
||||||
val next = nextItems.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 libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||||
val newest = mutableListOf<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
|
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) })
|
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? {
|
/** Resolve the current checkpoint even when the hero was painted from disk. */
|
||||||
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? {
|
||||||
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run {
|
||||||
if (item.type == BaseItemKind.SERIES) {
|
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||||
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||||
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
}?.id
|
||||||
}
|
} ?: return null
|
||||||
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
return readPlayback(api, userId, id)
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,18 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Audience metadata names a grouping; only this user's visible titles make Anime available. */
|
||||||
|
suspend fun hasAnimeTitles(userId: UUID): Boolean {
|
||||||
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
||||||
|
for (library in libraries) {
|
||||||
|
if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue
|
||||||
|
val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty()
|
||||||
|
if (titles.isNotEmpty()) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** A destination spans every matching accessible library; containers never become titles. */
|
/** A destination spans every matching accessible library; containers never become titles. */
|
||||||
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
|
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
||||||
|
|
@ -60,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
val response = api.itemsApi.getItems(
|
val response = api.itemsApi.getItems(
|
||||||
userId = userId, parentId = library.id, recursive = true,
|
userId = userId, parentId = library.id, recursive = true,
|
||||||
includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES),
|
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,
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset,
|
||||||
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
||||||
).content
|
).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. */
|
/** 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) {
|
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 key = "library:$destination"
|
||||||
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
||||||
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
||||||
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
||||||
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
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()
|
var current: List<LibraryTitle> = emptyList()
|
||||||
try {
|
try {
|
||||||
load().collect { chunk ->
|
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.TitlePerson
|
||||||
import org.justinvisser.wolfskin.design.TitleTrack
|
import org.justinvisser.wolfskin.design.TitleTrack
|
||||||
import org.justinvisser.wolfskin.design.TitleVersion
|
import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -52,7 +53,8 @@ class TitleReadFacade @Inject constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
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. */
|
/** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */
|
||||||
suspend fun findHeld(tmdb: Long, media: String): UUID? {
|
suspend fun findHeld(tmdb: Long, media: String): UUID? {
|
||||||
|
|
@ -76,23 +78,23 @@ class TitleReadFacade @Inject constructor(
|
||||||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
||||||
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
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)
|
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 }
|
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An episode opens its series with that exact episode as both play and watched target. */
|
/** An episode opens its series with that exact episode as both play and watched target. */
|
||||||
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
||||||
val file = cacheFile(id)
|
val file = cacheFile(id)
|
||||||
val opened = api.userLibraryApi.getItem(id).content
|
val opened = readItem(id)
|
||||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
|
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 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 {
|
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 selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
|
||||||
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
|
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 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 }
|
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 rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
|
||||||
val numbers = seasons.mapNotNull { it.indexNumber }
|
val numbers = seasons.mapNotNull { it.indexNumber }
|
||||||
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
||||||
|
|
@ -107,8 +109,23 @@ class TitleReadFacade @Inject constructor(
|
||||||
result.copy(trailerUrl = trailer)
|
result.copy(trailerUrl = trailer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
/** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */
|
||||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
private suspend fun readItem(id: UUID): BaseItemDto {
|
||||||
|
val item = api.userLibraryApi.getItem(id).content
|
||||||
|
if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item
|
||||||
|
val metadata = try {
|
||||||
|
api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id,
|
||||||
|
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id }
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
null
|
||||||
|
} ?: return item
|
||||||
|
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Track selection uses the same repository consumed by the upstream player. */
|
||||||
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
||||||
|
|
@ -149,7 +166,7 @@ class TitleReadFacade @Inject constructor(
|
||||||
} else emptyList()
|
} else emptyList()
|
||||||
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
||||||
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
|
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()
|
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
|
||||||
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
||||||
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
|
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
|
||||||
|
|
@ -175,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.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) })
|
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) },
|
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
||||||
rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
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,
|
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
||||||
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
||||||
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
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) })
|
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)
|
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.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(),
|
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) },
|
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,
|
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,
|
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(),
|
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 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 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()))
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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