Skip to content
Open
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 @@ -27,7 +27,6 @@ actual fun WebAuthenticator(
val authTabIntent =
AuthTabIntent
.Builder()
.setEphemeralBrowsingEnabled(true)
.build()
// Custom Tab is not kept in the activity history stack and it's removed from memory.
// It won't appear in the "Recent Apps" list
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.dhis2.mobile.login.main.domain.usecase.GetBiometricInfo
import org.dhis2.mobile.login.main.domain.usecase.GetDeviceEnrollmentUrl
import org.dhis2.mobile.login.main.domain.usecase.GetHasOtherAccounts
import org.dhis2.mobile.login.main.domain.usecase.GetInitialScreen
import org.dhis2.mobile.login.main.domain.usecase.GetOAuthLogoutUrl
import org.dhis2.mobile.login.main.domain.usecase.ImportDatabase
import org.dhis2.mobile.login.main.domain.usecase.LogOutUser
import org.dhis2.mobile.login.main.domain.usecase.LoginUser
Expand Down Expand Up @@ -80,6 +81,10 @@ internal val mainLoginModule =
GetDeviceEnrollmentUrl(get { parametersOf(params.get()) })
}

factory { params ->
GetOAuthLogoutUrl(get { parametersOf(params.get()) })
}

factory { params ->
ProcessDeviceEnrollment(get { parametersOf(params.get()) })
}
Expand Down Expand Up @@ -118,6 +123,7 @@ internal val mainLoginModule =
biometricLogin = get { parametersOf(context) },
loginUserWithOAuth = get { parametersOf(context) },
getDeviceEnrollmentUrl = get { parametersOf(context) },
getOAuthLogoutUrl = get { parametersOf(context) },
processDeviceEnrollment = get { parametersOf(context) },
updateTrackingPermission = get { parametersOf(context) },
updateBiometricPermission = get { parametersOf(context) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.dhis2.mobile.login.main.domain.usecase

import org.dhis2.mobile.commons.domain.UseCase
import org.dhis2.mobile.commons.error.DomainError
import org.dhis2.mobile.login.main.data.LoginRepository

class GetOAuthLogoutUrl(
private val repository: LoginRepository,
) : UseCase<String, String> {
override suspend fun invoke(input: String): Result<String> =
try {
// TODO: replace with a DHIS2 SDK-provided logout URL once available

Check warning on line 12 in login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetOAuthLogoutUrl.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=dhis2_dhis2-android-capture-app&issues=AZ83T7QzI97haYrzCoQv&open=AZ83T7QzI97haYrzCoQv&pullRequest=4962
// (e.g. d2.userModule().oauth2Handler().buildLogoutUrl(serverUrl)),
// routed through LoginRepository like getDeviceEnrollmentUrl.
Result.success("$input/dhis-web-commons-security/logout.action?redirect_uri=$REDIRECT_URI")
} catch (e: DomainError) {
Result.failure(e)
}

companion object {
private const val REDIRECT_URI = "dhis2oauth://oauth"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.dhis2.mobile.login.main.domain.usecase.GetAvailableUsernames
import org.dhis2.mobile.login.main.domain.usecase.GetBiometricInfo
import org.dhis2.mobile.login.main.domain.usecase.GetDeviceEnrollmentUrl
import org.dhis2.mobile.login.main.domain.usecase.GetHasOtherAccounts
import org.dhis2.mobile.login.main.domain.usecase.GetOAuthLogoutUrl
import org.dhis2.mobile.login.main.domain.usecase.LogOutUser
import org.dhis2.mobile.login.main.domain.usecase.LoginUser
import org.dhis2.mobile.login.main.domain.usecase.LoginUserWithOAuth
Expand Down Expand Up @@ -52,6 +53,7 @@ class CredentialsViewModel(
private val openIdLogin: OpenIdLogin,
private val loginUserWithOAuth: LoginUserWithOAuth,
private val getDeviceEnrollmentUrl: GetDeviceEnrollmentUrl,
private val getOAuthLogoutUrl: GetOAuthLogoutUrl,
private val processDeviceEnrollment: ProcessDeviceEnrollment,
private val updateTrackingPermission: UpdateTrackingPermission,
private val updateBiometricPermission: UpdateBiometricPermission,
Expand Down Expand Up @@ -97,6 +99,10 @@ class CredentialsViewModel(

private var loginJob: Job? = null

private var pendingOAuthLoginResult: LoginResult.Success? = null

private var appLinkJob: Job? = null

private val _credentialsScreenState = MutableStateFlow(initialState)
val credentialsScreenState =
_credentialsScreenState
Expand All @@ -108,14 +114,6 @@ class CredentialsViewModel(
initialValue = initialState,
)

init {
launchUseCase {
appLinkNavigation.appLink.collect { urlString ->
handleOAuthCallbacks(urlString)
}
}
}

private fun loadData() {
when (entryMode) {
CredentialsEntryMode.NEW_ACCOUNT_BASIC -> handleNewBasicAccount()
Expand Down Expand Up @@ -218,6 +216,7 @@ class CredentialsViewModel(
getDeviceEnrollmentUrl(serverUrl).fold(
onSuccess = { enrollmentURL ->
// First OAuth call (enrollment) - clear any previous OAuth sessions
startListeningForOAuthCallbacks()
navigator.navigate(
LoginScreenState.OauthAuthentication(
selectedServer = enrollmentURL,
Expand All @@ -241,10 +240,29 @@ class CredentialsViewModel(
}
}

// AppLinkNavigation is a single-delivery channel shared by every CredentialsViewModel alive
// on the back stack, so only the instance that launched the OAuth browser round-trip may
// collect it. Collection starts when the flow begins and stops when it terminates.
private fun startListeningForOAuthCallbacks() {
if (appLinkJob?.isActive == true) return
appLinkJob =
launchUseCase {
appLinkNavigation.appLink.collect { urlString ->
handleOAuthCallbacks(urlString)
}
}
}

private fun stopListeningForOAuthCallbacks() {
appLinkJob?.cancel()
appLinkJob = null
}

private fun handleOAuthCallbacks(urlString: String) {
// First check if there is any error
val error = urlString.substringAfter("error=", "").substringBefore('&')
if (error.isNotEmpty()) {
stopListeningForOAuthCallbacks()
_credentialsScreenState.update {
it.copy(
errorMessage = error,
Expand All @@ -268,6 +286,13 @@ class CredentialsViewModel(
return
}

// Logout callback after a successful OAuth login: resume the deferred actions
if (pendingOAuthLoginResult != null) {
completeOAuthLogin()
return
}

stopListeningForOAuthCallbacks()
_credentialsScreenState.update {
it.copy(
loginState = LoginState.Enabled,
Expand All @@ -276,12 +301,61 @@ class CredentialsViewModel(
}

private fun loginWithOAuthCode(code: String) {
startLoginJob {
loginUserWithOAuth(
serverUrl = serverUrl,
code = code,
_credentialsScreenState.update {
it.copy(
loginState = LoginState.Running,
errorMessage = null,
)
}
loginJob =
launchUseCase {
val result =
withMinimumDuration {
loginUserWithOAuth(
serverUrl = serverUrl,
code = code,
)
}
when (result) {
is LoginResult.Success -> {
// Defer entering the app until the server session cookie is cleared
pendingOAuthLoginResult = result
getOAuthLogoutUrl(serverUrl).fold(
onSuccess = { logoutUrl ->
navigator.navigate(
LoginScreenState.OauthAuthentication(
selectedServer = logoutUrl,
),
)
},
onFailure = {
// Best-effort: proceed into the app if the logout URL can't be built
completeOAuthLogin()
},
)
}

is LoginResult.Error -> {
stopListeningForOAuthCallbacks()
handleLoginResult(result)
_credentialsScreenState.update {
it.copy(loginState = LoginState.Enabled)
}
}
}
}
}

private fun completeOAuthLogin() {
val pending = pendingOAuthLoginResult ?: return
pendingOAuthLoginResult = null
stopListeningForOAuthCallbacks()
launchUseCase {
handleLoginResult(pending)
_credentialsScreenState.update {
it.copy(loginState = LoginState.Enabled)
}
}
}

private fun registerDevice(enrollmentIat: String) {
Expand All @@ -305,6 +379,7 @@ class CredentialsViewModel(
)
},
onFailure = { error ->
stopListeningForOAuthCallbacks()
_credentialsScreenState.update {
it.copy(
errorMessage = error.message,
Expand Down
Loading