From 452472931723b386d9620fb0b6f0be7e767cf62b Mon Sep 17 00:00:00 2001 From: max rovkin Date: Wed, 8 Jul 2026 20:08:52 +0500 Subject: [PATCH] Fix auth startup mirror detection --- .../data/repository/IKinoPubRepository.kt | 4 +- .../data/repository/KinoPubRepository.kt | 4 +- .../domain/interactor/auth/AuthInteractor.kt | 4 +- .../domain/interactor/auth/IAuthInteractor.kt | 4 +- .../kino/puber/ui/feature/auth/vm/AuthVM.kt | 122 +++++++++++------- .../puber/ui/feature/auth/vm/AuthVMTest.kt | 112 ++++++++++++++++ 6 files changed, 202 insertions(+), 48 deletions(-) create mode 100644 app/src/test/kotlin/com/kino/puber/ui/feature/auth/vm/AuthVMTest.kt diff --git a/app/src/main/java/com/kino/puber/data/repository/IKinoPubRepository.kt b/app/src/main/java/com/kino/puber/data/repository/IKinoPubRepository.kt index f1fc6c4..dda1f0b 100644 --- a/app/src/main/java/com/kino/puber/data/repository/IKinoPubRepository.kt +++ b/app/src/main/java/com/kino/puber/data/repository/IKinoPubRepository.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.Flow interface IKinoPubRepository { + fun isAuthenticated(): Boolean + fun getAuthState(): Flow -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kino/puber/data/repository/KinoPubRepository.kt b/app/src/main/java/com/kino/puber/data/repository/KinoPubRepository.kt index 07a45e2..f3f22fc 100644 --- a/app/src/main/java/com/kino/puber/data/repository/KinoPubRepository.kt +++ b/app/src/main/java/com/kino/puber/data/repository/KinoPubRepository.kt @@ -12,6 +12,8 @@ class KinoPubRepository( private val cryptoPreferenceRepository: ICryptoPreferenceRepository, ) : IKinoPubRepository { + override fun isAuthenticated(): Boolean = client.isAuthenticated() + override fun getAuthState(): Flow = channelFlow { if (client.isAuthenticated()) { send(AuthState.Success) @@ -51,4 +53,4 @@ class KinoPubRepository( // Code expired → loop restarts with new device code } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kino/puber/domain/interactor/auth/AuthInteractor.kt b/app/src/main/java/com/kino/puber/domain/interactor/auth/AuthInteractor.kt index a118e5a..0d5fe48 100644 --- a/app/src/main/java/com/kino/puber/domain/interactor/auth/AuthInteractor.kt +++ b/app/src/main/java/com/kino/puber/domain/interactor/auth/AuthInteractor.kt @@ -7,5 +7,7 @@ import kotlinx.coroutines.flow.Flow internal class AuthInteractor( private val repository: IKinoPubRepository ) : IAuthInteractor { + override fun isAuthenticated(): Boolean = repository.isAuthenticated() + override fun getAuthState(): Flow = repository.getAuthState() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kino/puber/domain/interactor/auth/IAuthInteractor.kt b/app/src/main/java/com/kino/puber/domain/interactor/auth/IAuthInteractor.kt index a13a112..60dffb9 100644 --- a/app/src/main/java/com/kino/puber/domain/interactor/auth/IAuthInteractor.kt +++ b/app/src/main/java/com/kino/puber/domain/interactor/auth/IAuthInteractor.kt @@ -5,5 +5,7 @@ import kotlinx.coroutines.flow.Flow interface IAuthInteractor { + fun isAuthenticated(): Boolean + fun getAuthState(): Flow -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kino/puber/ui/feature/auth/vm/AuthVM.kt b/app/src/main/java/com/kino/puber/ui/feature/auth/vm/AuthVM.kt index c0f5d00..4aa50fc 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/auth/vm/AuthVM.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/auth/vm/AuthVM.kt @@ -40,6 +40,7 @@ internal class AuthVM( private var timerJob: Job? = null private var loadingHintJob: Job? = null private var hasRetriedAlternativeDomainAfterAuthError = false + private var startupMode = AuthStartupMode.LoginRequired override fun onStart() { startAuth() @@ -49,69 +50,86 @@ internal class AuthVM( authJob?.cancel() timerJob?.cancel() loadingHintJob?.cancel() + startupMode = if (authInteractor.isAuthenticated()) { + AuthStartupMode.ExistingSession + } else { + AuthStartupMode.LoginRequired + } + hasRetriedAlternativeDomainAfterAuthError = false updateViewState( AuthViewState.Loading( - statusMessage = resources.getString(R.string.api_domain_auto_searching), + statusMessage = startupMode.loadingStatusMessage(), apiDomainDialog = currentDialogState(), ) ) scheduleLoadingHint() authJob = launch { - when (val result = apiDomainInteractor.autoResolveWorkingDomain()) { - ApiDomainAutoResolveResult.NotFound -> { - updateViewState( - AuthViewState.Loading( - showMirrorHint = true, - statusMessage = resources.getString(R.string.api_domain_auto_failed), - apiDomainDialog = apiDomainInteractor.getState().toDialogState(), - ) + if (startupMode == AuthStartupMode.ExistingSession && !resolveApiDomainForExistingSession()) { + return@launch + } + + collectAuthState() + } + } + + private suspend fun resolveApiDomainForExistingSession(): Boolean { + return when (val result = apiDomainInteractor.autoResolveWorkingDomain()) { + ApiDomainAutoResolveResult.NotFound -> { + updateViewState( + AuthViewState.Loading( + showMirrorHint = true, + statusMessage = resources.getString(R.string.api_domain_auto_failed), + apiDomainDialog = apiDomainInteractor.getState().toDialogState(), ) - return@launch - } + ) + false + } - is ApiDomainAutoResolveResult.Success -> { - updateViewState( - AuthViewState.Loading( - apiDomainDialog = currentDialogState(), - ) + is ApiDomainAutoResolveResult.Success -> { + updateViewState( + AuthViewState.Loading( + apiDomainDialog = currentDialogState(), ) - if (result.changed) { - showMessage(resources.getString(R.string.api_domain_auto_switched, result.state.domain)) - } + ) + if (result.changed) { + showMessage(resources.getString(R.string.api_domain_auto_switched, result.state.domain)) } + true } + } + } - authInteractor.getAuthState() - .flatMapConcat { state -> - when (state) { - is AuthState.Success -> - deviceInfoInteractor.setDeviceInformation() - .map { state } + private suspend fun collectAuthState() { + authInteractor.getAuthState() + .flatMapConcat { state -> + when (state) { + is AuthState.Success -> + deviceInfoInteractor.setDeviceInformation() + .map { state } - else -> flowOf(state) - } + else -> flowOf(state) } - .collect { - when (it) { - is AuthState.Code -> { - loadingHintJob?.cancel() - updateViewState( - AuthViewState.Content( - code = it.code, - url = it.url, - apiDomainDialog = currentDialogState(), - ) + } + .collect { + when (it) { + is AuthState.Code -> { + loadingHintJob?.cancel() + updateViewState( + AuthViewState.Content( + code = it.code, + url = it.url, + apiDomainDialog = currentDialogState(), ) - startTimer(it.expireTimeSeconds) - } + ) + startTimer(it.expireTimeSeconds) + } - AuthState.Success -> { - timerJob?.cancel() - router.newRootScreen(router.screens.main()) - } + AuthState.Success -> { + timerJob?.cancel() + router.newRootScreen(router.screens.main()) } } - } + } } override fun onAction(action: UIAction) { @@ -127,6 +145,10 @@ internal class AuthVM( override fun dispatchError(error: ErrorEntity) { showMessage(error.message) + if (startupMode == AuthStartupMode.LoginRequired) { + return + } + if (!hasRetriedAlternativeDomainAfterAuthError) { hasRetriedAlternativeDomainAfterAuthError = true updateViewState( @@ -256,6 +278,18 @@ internal class AuthVM( ) } + private fun AuthStartupMode.loadingStatusMessage(): String? { + return when (this) { + AuthStartupMode.LoginRequired -> null + AuthStartupMode.ExistingSession -> resources.getString(R.string.api_domain_auto_searching) + } + } + + private enum class AuthStartupMode { + LoginRequired, + ExistingSession, + } + private companion object { private const val SECONDS_IN_MINUTE = 60 private const val LOADING_HINT_DELAY_MS = 20_000L diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/auth/vm/AuthVMTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/auth/vm/AuthVMTest.kt new file mode 100644 index 0000000..186d8a6 --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/auth/vm/AuthVMTest.kt @@ -0,0 +1,112 @@ +package com.kino.puber.ui.feature.auth.vm + +import com.kino.puber.core.error.ErrorEntity +import com.kino.puber.core.error.ErrorHandler +import com.kino.puber.core.ui.navigation.AppRouter +import com.kino.puber.domain.interactor.api.ApiDomainAutoResolveResult +import com.kino.puber.domain.interactor.api.ApiDomainInteractor +import com.kino.puber.domain.interactor.api.ApiDomainState +import com.kino.puber.domain.interactor.auth.IAuthInteractor +import com.kino.puber.domain.interactor.auth.model.AuthState +import com.kino.puber.domain.interactor.device.IDeviceInfoInteractor +import com.kino.puber.ui.feature.auth.model.AuthViewState +import com.kino.puber.util.FakeResourceProvider +import com.kino.puber.util.MainDispatcherExtension +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import java.io.IOException + +class AuthVMTest { + + companion object { + @JvmField + @RegisterExtension + val mainDispatcher = MainDispatcherExtension() + } + + private val router = mockk(relaxed = true) + private val errorHandler = mockk(relaxed = true) + private val authInteractor = mockk() + private val deviceInfoInteractor = mockk(relaxed = true) + private val apiDomainInteractor = mockk(relaxed = true) + + private fun createVM(): AuthVM { + every { apiDomainInteractor.getState() } returns ApiDomainState( + domain = "api.example.com", + customDomain = null, + ) + return AuthVM( + authInteractor = authInteractor, + deviceInfoInteractor = deviceInfoInteractor, + apiDomainInteractor = apiDomainInteractor, + resources = FakeResourceProvider(), + errorHandler = errorHandler, + router = router, + ) + } + + @Test + fun onStart_skipsApiDomainAutoResolveAndShowsDeviceCode_whenNoTokenExists() { + every { authInteractor.isAuthenticated() } returns false + every { authInteractor.getAuthState() } returns flowOf( + AuthState.Code( + code = "123456", + url = "https://example.com", + expireTimeSeconds = 120, + ) + ) + val vm = createVM() + + vm.testOnStart() + + val state = vm.testStateValue + assertTrue(state is AuthViewState.Content) + assertEquals("123456", (state as AuthViewState.Content).code) + assertEquals("https://example.com", state.url) + coVerify(exactly = 0) { apiDomainInteractor.autoResolveWorkingDomain() } + } + + @Test + fun onStart_keepsDomainAutoResolveFallback_whenTokenExists() { + every { authInteractor.isAuthenticated() } returns true + every { authInteractor.getAuthState() } returns flowOf(AuthState.Success) + coEvery { apiDomainInteractor.autoResolveWorkingDomain() } returns ApiDomainAutoResolveResult.NotFound + val vm = createVM() + + vm.testOnStart() + + val state = vm.testStateValue + assertTrue(state is AuthViewState.Loading) + assertTrue((state as AuthViewState.Loading).showMirrorHint) + verify(exactly = 0) { authInteractor.getAuthState() } + } + + @Test + fun dispatchError_doesNotDetectAlternativeDomain_whenLoginRequired() { + every { authInteractor.isAuthenticated() } returns false + every { authInteractor.getAuthState() } returns flow { throw IOException("Login failed") } + every { errorHandler.proceedInvoke(any(), any()) } answers { + secondArg<((ErrorEntity) -> Unit)?>()?.invoke( + ErrorEntity( + message = "Login failed", + code = "login_failed", + ) + ) + } + val vm = createVM() + + vm.testOnStart() + + verify(exactly = 1) { errorHandler.proceedInvoke(any(), any()) } + coVerify(exactly = 0) { apiDomainInteractor.detectAndSaveAlternativeBuiltInDomain() } + } +}