diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 28a6672..68e1c18 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,10 +63,15 @@ + (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. --> diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt index ed2cafd..e4c1297 100644 --- a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -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 { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt index b76d5d4..59a4751 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt @@ -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(), ) { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index 787e5ea..7c25643 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -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 = 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" + } }