Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.Flow

interface IKinoPubRepository {

fun isAuthenticated(): Boolean

fun getAuthState(): Flow<AuthState>

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class KinoPubRepository(
private val cryptoPreferenceRepository: ICryptoPreferenceRepository,
) : IKinoPubRepository {

override fun isAuthenticated(): Boolean = client.isAuthenticated()

override fun getAuthState(): Flow<AuthState> = channelFlow {
if (client.isAuthenticated()) {
send(AuthState.Success)
Expand Down Expand Up @@ -51,4 +53,4 @@ class KinoPubRepository(
// Code expired → loop restarts with new device code
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthState> = repository.getAuthState()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ import kotlinx.coroutines.flow.Flow

interface IAuthInteractor {

fun isAuthenticated(): Boolean

fun getAuthState(): Flow<AuthState>
}
}
122 changes: 78 additions & 44 deletions app/src/main/java/com/kino/puber/ui/feature/auth/vm/AuthVM.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions app/src/test/kotlin/com/kino/puber/ui/feature/auth/vm/AuthVMTest.kt
Original file line number Diff line number Diff line change
@@ -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<AppRouter>(relaxed = true)
private val errorHandler = mockk<ErrorHandler>(relaxed = true)
private val authInteractor = mockk<IAuthInteractor>()
private val deviceInfoInteractor = mockk<IDeviceInfoInteractor>(relaxed = true)
private val apiDomainInteractor = mockk<ApiDomainInteractor>(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() }
}
}
Loading