Fix Outlook sign-in crash and complete the OAuth login flow #2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user