Fix Outlook sign-in crash and complete the OAuth login flow #2

Merged
JMR-dev merged 1 commits from fix-outlook-login-flow into main 2026-06-30 04:26:09 +00:00
4 changed files with 76 additions and 6 deletions
+6 -1
View File
@@ -63,10 +63,15 @@
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
(mobile/desktop) redirect in the Azure app registration. -->
(mobile/desktop) redirect in the Azure app registration.
AppAuth's RedirectUriReceiverActivity extends AppCompatActivity, so it needs an
AppCompat theme; without this it inherits the app's Theme.Material shell and crashes
("You need to use a Theme.AppCompat theme") when the redirect launches it. We reuse the
translucent theme AppAuth itself applies to AuthorizationManagementActivity. -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -45,6 +45,12 @@ class OutlookAuthManager @Inject constructor(
/** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */
val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank()
/**
* Builds the browser/Custom-Tab intent that starts the Microsoft sign-in.
*
* Throws [android.content.ActivityNotFoundException] when no usable browser is installed;
* callers must guard the launch and surface that as an error rather than crashing.
*/
fun createAuthIntent(): Intent {
val request = AuthorizationRequest.Builder(
serviceConfig,
@@ -55,18 +61,43 @@ class OutlookAuthManager @Inject constructor(
// One consent covering both resources; per-resource access tokens are minted later.
.setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE")
.build()
return AuthorizationService(context).getAuthorizationRequestIntent(request)
// The returned intent is self-contained, so dispose the service (and its Custom-Tabs
// warmup binding) immediately instead of leaking one per button tap.
val service = AuthorizationService(context)
return try {
service.getAuthorizationRequestIntent(request)
} finally {
service.dispose()
}
}
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
val response = AuthorizationResponse.fromIntent(responseIntent)
val exception = AuthorizationException.fromIntent(responseIntent)
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
val authCode = response.authorizationCode
?: throw exception ?: IllegalStateException("No authorization code was returned")
// Microsoft issues one access token per resource, so the authorization-code exchange must
// name a single resource. AppAuth's createTokenExchangeRequest() sends no scope, which makes
// Microsoft reject our multi-resource consent code with AADSTS70011 ("must include a 'scope'
// input parameter"). Request the OIDC scopes plus the Exchange Online resource so we still get
// an id_token (for the email) and a refresh token to mint the Graph token from later.
// This mirrors every field createTokenExchangeRequest() sets — including the nonce, which
// AppAuth checks against the id_token's nonce claim (omitting it fails id_token validation).
val exchangeRequest = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID)
.setGrantType(GrantTypeValues.AUTHORIZATION_CODE)
.setAuthorizationCode(authCode)
.setRedirectUri(Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI))
.setCodeVerifier(response.request.codeVerifier)
.setNonce(response.request.nonce)
.setScope("openid email $OFFLINE $OUTLOOK_SCOPE")
.build()
val service = AuthorizationService(context)
try {
val tokenResponse = suspendCancellableCoroutine { continuation ->
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
service.performTokenRequest(exchangeRequest) { token, error ->
if (token != null) {
continuation.resume(token)
} else {
@@ -101,7 +101,15 @@ fun AccountSetupScreen(
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) },
onClick = {
viewModel.outlookAuthIntent().fold(
onSuccess = { intent ->
runCatching { outlookLauncher.launch(intent) }
.onFailure { viewModel.onOutlookLaunchFailed(it) }
},
onFailure = { viewModel.onOutlookLaunchFailed(it) },
)
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import android.content.ActivityNotFoundException
import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -33,7 +35,22 @@ class AccountSetupViewModel @Inject constructor(
val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured
fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent()
/**
* Builds the Microsoft sign-in intent. Wrapped in [Result] because AppAuth throws
* (e.g. [ActivityNotFoundException] when no browser is available) while building it; the
* screen surfaces a failure as an error instead of letting it crash the app.
*/
fun outlookAuthIntent(): Result<Intent> = runCatching { outlookAuthManager.createAuthIntent() }
/** Reports a failure to build or launch the sign-in intent through the error snackbar. */
fun onOutlookLaunchFailed(error: Throwable) {
val message = if (error is ActivityNotFoundException) {
"No web browser is available for Microsoft sign-in"
} else {
error.message ?: "Couldn't start Microsoft sign-in"
}
_state.update { it.copy(status = SetupStatus.IDLE, error = message) }
}
fun onOutlookResult(data: Intent?) {
if (data == null) {
@@ -47,10 +64,19 @@ class AccountSetupViewModel @Inject constructor(
accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow()
}.fold(
onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } },
onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } },
onFailure = { e ->
// Stripped from release builds by the Log.d ProGuard rule (keeps any account
// address / token detail out of shipped logs); visible in debug for diagnosis.
Log.d(TAG, "Outlook sign-in failed after redirect", e)
_state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") }
},
)
}
}
fun consumeError() = _state.update { it.copy(error = null) }
private companion object {
const val TAG = "AccountSetupVM"
}
}