Cache DNS answers and paint saved people before launch revalidation
Some checks failed
review/security-claude BLOCK: DnsConnectionRetry crashes app on Jellyfin WebSocket (NoSuchElementException)

This commit is contained in:
justinzeus 2026-11-07 00:09:57 +01:00
parent f4fb6e22c9
commit 327f631f0d
18 changed files with 763 additions and 44 deletions

View file

@ -32,6 +32,8 @@ import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
@ -116,9 +118,6 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.restoreLastSession() } returns
// RestoredSession.Success(currentUser)
viewModel.handleAppStart()
@ -132,7 +131,7 @@ class TestMainActivityViewModel {
destination as SetupDestination.AppContent
Assert.assertEquals(currentUser, destination.current)
}
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@ -150,7 +149,7 @@ class TestMainActivityViewModel {
viewModel.handleAppStart()
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
val args = mutableListOf<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(args)) }
@ -165,6 +164,20 @@ class TestMainActivityViewModel {
}
}
@Test
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
setupPreferences { signInAutomatically = true }
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
val release = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() }
viewModel.handleAppStart()
runCurrent()
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
assertFalse(release.isCompleted)
release.complete(Unit)
}
@Test
fun `Test auto sign in, hot load, with protected profile`() =
runTest {
@ -178,7 +191,7 @@ class TestMainActivityViewModel {
coEvery {
serverRepository.tryChangeUser(serverId, userId)
} returns null
coEvery { serverRepository.restoreLastSession() } returns
coEvery { serverRepository.restoreSavedSession() } returns
RestoredSession.ServerOnly(server)
viewModel.handleAppStart()
@ -204,7 +217,7 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreLastSession() } returns
coEvery { serverRepository.restoreSavedSession() } returns
RestoredSession.ServerOnly(server)
coEvery {
serverRepository.tryChangeUser(serverId, userId)
@ -212,7 +225,7 @@ class TestMainActivityViewModel {
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
@ -232,11 +245,11 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
@ -254,7 +267,7 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreLastSession() } returns
coEvery { serverRepository.restoreSavedSession() } returns
RestoredSession.Success(currentUser)
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
@ -271,7 +284,7 @@ class TestMainActivityViewModel {
// destination as SetupDestination.AppContent
// Assert.assertEquals(currentUser, destination.current)
// }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}

View file

@ -0,0 +1,102 @@
package com.github.damontecres.wholphin.services
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.Before
import org.junit.Test
/** Saved identities can paint offline without unlocking protected logins or accepting a late identity. */
@OptIn(ExperimentalCoroutinesApi::class)
class ServerSavedSessionTest {
private val dispatcher = StandardTestDispatcher()
private val server = JellyfinServer(UUID.randomUUID(), "Saved server", "https://jellyfin.test", "12.1.0")
private val 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 dao = mockk<JellyfinServerDao>(relaxed = true)
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))
every { dao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
every { dao.addOrUpdateUser(any()) } answers { firstArg() }
every { api.userApi } returns users
every { api.systemApi } returns system
coEvery { users.getCurrentUser() } returns successResponse(dto)
coEvery { system.getPublicSystemInfo() } returns successResponse(info)
}
@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))) {
every { dao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(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 = user.copy(id = UUID.randomUUID(), name = "Other person")
every { recent.get() } returns MostRecentServer.from(CurrentUser(server, selected))
every { dao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(selected))
repository.restoreSavedSession()
pending.complete(Unit)
refresh.await()
assertEquals(selected, repository.current.value!!.user)
assertNull(repository.currentUserDto)
}
@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)
}
}

View file

@ -62,6 +62,7 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import org.justinvisser.wolfskin.conductarr.PersistentDns
import javax.inject.Singleton
@Module
@ -91,12 +92,11 @@ object TestModule {
@StandardOkHttpClient
@Provides
@Singleton
fun okHttpClient() =
OkHttpClient
.Builder()
.apply {
// TODO user agent, timeouts, logging, etc
}.build()
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
@Provides
@Singleton
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
@AuthOkHttpClient
@Provides

View file

@ -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))
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)
}
}
}
}
}