0
0
mirror of https://github.com/thunderbird/thunderbird-android.git synced 2024-09-20 12:12:15 +02:00

Add FinishOAuthSignIn usecase

This commit is contained in:
Wolf-Martell Montwé 2023-07-18 15:19:38 +02:00
parent e658b8256b
commit 615e16895f
No known key found for this signature in database
GPG Key ID: 6D45B21512ACBF72
14 changed files with 468 additions and 58 deletions

View File

@ -23,6 +23,7 @@ dependencies {
implementation(libs.appauth)
implementation(libs.androidx.compose.material)
implementation(libs.timber)
testImplementation(projects.core.ui.compose.testing)
}

View File

@ -2,8 +2,10 @@ package app.k9mail.feature.account.oauth
import app.k9mail.core.common.coreCommonModule
import app.k9mail.feature.account.oauth.data.AuthorizationRepository
import app.k9mail.feature.account.oauth.data.AuthorizationStateRepository
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
import app.k9mail.feature.account.oauth.domain.usecase.FinishOAuthSignIn
import app.k9mail.feature.account.oauth.domain.usecase.GetOAuthRequestIntent
import app.k9mail.feature.account.oauth.domain.usecase.SuggestServerName
import app.k9mail.feature.account.oauth.ui.AccountOAuthViewModel
@ -28,6 +30,10 @@ val featureAccountOAuthModule: Module = module {
)
}
factory<DomainContract.AuthorizationStateRepository> {
AuthorizationStateRepository()
}
factory<UseCase.SuggestServerName> { SuggestServerName() }
factory<UseCase.GetOAuthRequestIntent> {
@ -37,9 +43,12 @@ val featureAccountOAuthModule: Module = module {
)
}
factory<UseCase.FinishOAuthSignIn> { FinishOAuthSignIn(repository = get()) }
viewModel {
AccountOAuthViewModel(
getOAuthRequestIntent = get(),
finishOAuthSignIn = get(),
)
}
}

View File

@ -0,0 +1,24 @@
package app.k9mail.feature.account.oauth.data
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import net.openid.appauth.AuthState
import org.json.JSONException
import timber.log.Timber
fun AuthState.toAuthorizationState(): AuthorizationState {
return try {
AuthorizationState(state = jsonSerializeString())
} catch (e: JSONException) {
Timber.e(e, "Error serializing AuthorizationState")
AuthorizationState()
}
}
fun AuthorizationState.toAuthState(): AuthState {
return try {
state?.let { AuthState.jsonDeserialize(it) } ?: AuthState()
} catch (e: JSONException) {
Timber.e(e, "Error deserializing AuthorizationState")
AuthState()
}
}

View File

@ -5,10 +5,17 @@ import androidx.core.net.toUri
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import timber.log.Timber
class AuthorizationRepository(
private val service: AuthorizationService,
@ -23,6 +30,46 @@ class AuthorizationRepository(
)
}
override suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse? {
return try {
AuthorizationResponse.fromIntent(intent)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Error deserializing AuthorizationResponse")
null
}
}
override suspend fun getAuthorizationException(intent: Intent): AuthorizationException? {
return try {
AuthorizationException.fromIntent(intent)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Error deserializing AuthorizationException")
null
}
}
override suspend fun getExchangeToken(
authorizationState: AuthorizationState,
response: AuthorizationResponse,
): AuthorizationResult = suspendCoroutine { continuation ->
val tokenRequest = response.createTokenExchangeRequest()
val authState = authorizationState.toAuthState()
service.performTokenRequest(tokenRequest) { tokenResponse, authorizationException ->
authState.update(tokenResponse, authorizationException)
val result = if (authorizationException != null) {
AuthorizationResult.Failure(authorizationException)
} else if (tokenResponse != null) {
AuthorizationResult.Success(authState.toAuthorizationState())
} else {
AuthorizationResult.Failure(Exception("Unknown error"))
}
continuation.resume(result)
}
}
private fun createAuthorizationRequestIntent(configuration: OAuthConfiguration, emailAddress: String): Intent {
val serviceConfig = AuthorizationServiceConfiguration(
configuration.authorizationEndpoint.toUri(),

View File

@ -0,0 +1,12 @@
package app.k9mail.feature.account.oauth.data
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
class AuthorizationStateRepository : DomainContract.AuthorizationStateRepository {
override suspend fun isAuthorized(authorizationState: AuthorizationState): Boolean {
val authState = authorizationState.toAuthState()
return authState.isAuthorized
}
}

View File

@ -1,7 +1,12 @@
package app.k9mail.feature.account.oauth.domain
import android.content.Intent
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
interface DomainContract {
@ -13,6 +18,14 @@ interface DomainContract {
fun interface GetOAuthRequestIntent {
fun execute(hostname: String, emailAddress: String): AuthorizationIntentResult
}
fun interface FinishOAuthSignIn {
suspend fun execute(authorizationState: AuthorizationState, intent: Intent): AuthorizationResult
}
fun interface CheckIsAuthorized {
suspend fun execute(authorizationState: AuthorizationState): Boolean
}
}
interface AuthorizationRepository {
@ -20,5 +33,17 @@ interface DomainContract {
configuration: OAuthConfiguration,
emailAddress: String,
): AuthorizationIntentResult
suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse?
suspend fun getAuthorizationException(intent: Intent): AuthorizationException?
suspend fun getExchangeToken(
authorizationState: AuthorizationState,
response: AuthorizationResponse,
): AuthorizationResult
}
interface AuthorizationStateRepository {
suspend fun isAuthorized(authorizationState: AuthorizationState): Boolean
}
}

View File

@ -0,0 +1,16 @@
package app.k9mail.feature.account.oauth.domain.entity
sealed interface AuthorizationResult {
data class Success(
val state: AuthorizationState,
) : AuthorizationResult
data class Failure(
val error: Exception,
) : AuthorizationResult
object BrowserNotAvailable : AuthorizationResult
object Canceled : AuthorizationResult
}

View File

@ -0,0 +1,13 @@
package app.k9mail.feature.account.oauth.domain.usecase
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
class CheckIsAuthorized(
private val repository: DomainContract.AuthorizationStateRepository,
) : UseCase.CheckIsAuthorized {
override suspend fun execute(authorizationState: AuthorizationState): Boolean {
return repository.isAuthorized(authorizationState)
}
}

View File

@ -0,0 +1,24 @@
package app.k9mail.feature.account.oauth.domain.usecase
import android.content.Intent
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
class FinishOAuthSignIn(
private val repository: DomainContract.AuthorizationRepository,
) : UseCase.FinishOAuthSignIn {
override suspend fun execute(authorizationState: AuthorizationState, intent: Intent): AuthorizationResult {
val response = repository.getAuthorizationResponse(intent)
val exception = repository.getAuthorizationException(intent)
return if (response != null) {
repository.getExchangeToken(authorizationState, response)
} else if (exception != null) {
AuthorizationResult.Failure(exception)
} else {
AuthorizationResult.Canceled
}
}
}

View File

@ -24,6 +24,11 @@ interface AccountOAuthContract {
)
sealed interface Event {
data class OnOAuthResult(
val resultCode: Int,
val data: Intent?,
) : Event
object SignInClicked : Event
object OnNextClicked : Event
object OnBackClicked : Event
@ -43,7 +48,9 @@ interface AccountOAuthContract {
sealed interface Error {
object NotSupported : Error
object NetworkError : Error
object UnknownError : Error
object Cancelled : Error
object BrowserNotAvailable : Error
data class Unknown(val error: Exception) : Error
}
}

View File

@ -1,6 +1,5 @@
package app.k9mail.feature.account.oauth.ui
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -27,10 +26,7 @@ fun AccountOAuthScreen(
val oAuthLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) {
if (it.resultCode == Activity.RESULT_OK && it.data != null) {
// TODO handle success
}
// TODO handle error
viewModel.event(Event.OnOAuthResult(it.resultCode, it.data))
}
val (state, dispatch) = viewModel.observe { effect ->

View File

@ -1,17 +1,23 @@
package app.k9mail.feature.account.oauth.ui
import android.app.Activity
import android.content.Intent
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase.GetOAuthRequestIntent
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.ViewModel
import kotlinx.coroutines.launch
class AccountOAuthViewModel(
initialState: State = State(),
private val getOAuthRequestIntent: GetOAuthRequestIntent,
private val getOAuthRequestIntent: UseCase.GetOAuthRequestIntent,
private val finishOAuthSignIn: UseCase.FinishOAuthSignIn,
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override fun initState(state: State) {
@ -22,24 +28,19 @@ class AccountOAuthViewModel(
override fun event(event: Event) {
when (event) {
Event.SignInClicked -> launchOAuth()
is Event.OnOAuthResult -> onOAuthResult(event.resultCode, event.data)
Event.SignInClicked -> onSignIn()
Event.OnNextClicked -> TODO()
Event.OnBackClicked -> navigateBack()
Event.OnRetryClicked -> {
updateState { state ->
state.copy(
error = null,
)
}
launchOAuth()
}
Event.OnRetryClicked -> onRetry()
}
}
private fun launchOAuth() {
private fun onSignIn() {
val result = getOAuthRequestIntent.execute(
hostname = state.value.hostname,
emailAddress = state.value.emailAddress,
@ -60,5 +61,57 @@ class AccountOAuthViewModel(
}
}
private fun onRetry() {
updateState { state ->
state.copy(
error = null,
)
}
onSignIn()
}
private fun onOAuthResult(resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK && data != null) {
finishSignIn(data)
} else {
updateState { state ->
state.copy(error = Error.Cancelled)
}
}
}
private fun finishSignIn(data: Intent) {
updateState { state ->
state.copy(
isLoading = true,
)
}
viewModelScope.launch {
when (val result = finishOAuthSignIn.execute(state.value.authorizationState, data)) {
AuthorizationResult.BrowserNotAvailable -> updateErrorState(Error.BrowserNotAvailable)
AuthorizationResult.Canceled -> updateErrorState(Error.Cancelled)
is AuthorizationResult.Failure -> updateErrorState(Error.Unknown(result.error))
is AuthorizationResult.Success -> {
updateState { state ->
state.copy(
authorizationState = result.state,
isLoading = false,
)
}
navigateNext()
}
}
}
}
private fun updateErrorState(error: Error) = updateState { state ->
state.copy(
error = error,
isLoading = false,
)
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
private fun navigateNext() = emitEffect(Effect.NavigateNext(state.value.authorizationState))
}

View File

@ -4,9 +4,13 @@ import android.content.Intent
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.test.runTest
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import org.junit.Test
class GetOAuthRequestIntentTest {
@ -72,5 +76,20 @@ class GetOAuthRequestIntentTest {
return AuthorizationIntentResult.Success(intent)
}
override suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse? {
TODO("Not yet implemented")
}
override suspend fun getAuthorizationException(intent: Intent): AuthorizationException? {
TODO("Not yet implemented")
}
override suspend fun getExchangeToken(
authorizationState: AuthorizationState,
response: AuthorizationResponse,
): AuthorizationResult {
TODO("Not yet implemented")
}
}
}

View File

@ -1,9 +1,12 @@
package app.k9mail.feature.account.oauth.ui
import android.app.Activity
import android.content.Intent
import app.cash.turbine.testIn
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event
@ -11,6 +14,7 @@ import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
import assertk.assertThat
import assertk.assertions.assertThatAndTurbinesConsumed
import assertk.assertions.isEqualTo
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -23,26 +27,10 @@ class AccountOAuthViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.Success(intent = Intent())
},
)
@Test
fun `should launch OAuth when SignInClicked event received`() = runTest {
val initialState = State(
hostname = "example.com",
emailAddress = "test@example.com",
)
val intent = Intent()
val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.Success(intent = intent)
},
initialState = initialState,
)
val initialState = defaultState
val testSubject = createTestSubject(initialState = initialState)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
@ -66,14 +54,9 @@ class AccountOAuthViewModelTest {
@Test
fun `should show error when SignInClicked event received and OAuth is not supported`() = runTest {
val initialState = State(
hostname = "example.com",
emailAddress = "test@example.com",
)
val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.NotSupported
},
val initialState = defaultState
val testSubject = createTestSubject(
authorizationIntentResult = AuthorizationIntentResult.NotSupported,
initialState = initialState,
)
@ -100,19 +83,10 @@ class AccountOAuthViewModelTest {
@Test
fun `should remove error and launch OAuth when OnRetryClicked event received`() = runTest {
val initialState = State(
hostname = "example.com",
emailAddress = "test@example.com",
val initialState = defaultState.copy(
error = Error.NotSupported,
)
val intent = Intent()
val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.Success(intent = intent)
},
initialState = initialState,
)
val testSubject = createTestSubject(initialState = initialState)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
@ -127,7 +101,7 @@ class AccountOAuthViewModelTest {
testSubject.event(Event.OnRetryClicked)
assertThat(stateTurbine.awaitItem()).isEqualTo(
initialState.copy(error = null)
initialState.copy(error = null),
)
assertThatAndTurbinesConsumed(
@ -138,9 +112,171 @@ class AccountOAuthViewModelTest {
}
}
@Test
fun `should finish OAuth sign in when onOAuthResult received with success`() = runTest {
val initialState = defaultState
val authorizationState = AuthorizationState(state = "state")
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Success(authorizationState),
initialState = initialState
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent))
val loadingState = initialState.copy(isLoading = true)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(loadingState)
}
val successState = loadingState.copy(
isLoading = false,
authorizationState = authorizationState,
)
assertThat(stateTurbine.awaitItem()).isEqualTo(successState)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext(authorizationState))
}
}
@Test
fun `should set error state when onOAuthResult received with canceled`() = runTest {
val initialState = defaultState
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Canceled,
initialState = initialState
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_CANCELED, data = intent))
val failureState = initialState.copy(
error = Error.Cancelled,
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should finish OAuth sign in when onOAuthResult received with success but authorization result is cancelled`() = runTest {
val initialState = defaultState
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Canceled,
initialState = initialState
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent))
val loadingState = initialState.copy(isLoading = true)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(loadingState)
}
val failureState = loadingState.copy(
isLoading = false,
error = Error.Cancelled,
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should finish OAuth sign in when onOAuthResult received with success but authorization result is failure`() = runTest {
val initialState = defaultState
val failure = Exception("failure")
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Failure(failure),
initialState = initialState
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent))
val loadingState = initialState.copy(isLoading = true)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(loadingState)
}
val failureState = loadingState.copy(
isLoading = false,
error = Error.Unknown(failure),
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event received`() = runTest {
val viewModel = testSubject
val viewModel = createTestSubject()
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
@ -161,4 +297,32 @@ class AccountOAuthViewModelTest {
isEqualTo(Effect.NavigateBack)
}
}
private companion object {
val defaultState = State(
hostname = "example.com",
emailAddress = "test@example.com",
)
val intent = Intent()
fun createTestSubject(
authorizationIntentResult: AuthorizationIntentResult = AuthorizationIntentResult.Success(intent = intent),
authorizationResult: AuthorizationResult = AuthorizationResult.Success(AuthorizationState()),
isGoogleSignIn: Boolean = false,
initialState: State = State(),
) = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
authorizationIntentResult
},
finishOAuthSignIn = { _, _ ->
delay(50)
authorizationResult
},
checkIsGoogleSignIn = { _ ->
isGoogleSignIn
},
initialState = initialState,
)
}
}