From 0813091af744f31cbe52590bb69a491c487e67e3 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Thu, 1 Oct 2026 17:40:57 +0300 Subject: [PATCH] Add browser chooser to Web eID actions --- .../ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt | 63 ++++++-- .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 34 ++-- .../kotlin/ee/ria/DigiDoc/MainActivity.kt | 45 +++++- .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 145 ++++++++++++++++-- .../ui/component/webeid/WebEidBrowser.kt | 14 ++ .../webeid/WebEidBrowserBottomSheet.kt | 116 ++++++++++++++ .../ria/DigiDoc/utils/WebEidBrowserCache.kt | 55 +++++++ .../ee/ria/DigiDoc/utils/WebEidUriUtil.kt | 8 + .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 65 ++++++-- app/src/main/res/values-et/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../DigiDoc/utils/WebEidBrowserCacheTest.kt | 73 +++++++++ 12 files changed, 564 insertions(+), 60 deletions(-) create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowser.kt create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowserBottomSheet.kt create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCache.kt create mode 100644 app/src/test/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCacheTest.kt diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt index c044e62b4..c5f503f5d 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt @@ -6,6 +6,7 @@ package ee.ria.DigiDoc.utils import android.net.Uri +import ee.ria.DigiDoc.BuildConfig import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -13,6 +14,8 @@ import org.junit.Assert.assertTrue import org.junit.Test class WebEidUriUtilTest { + private val appLinkHostUri = "https://${BuildConfig.APP_LINKS_HOST}" + @Test fun isWebEidUri_customScheme_auth() { assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("web-eid-mobile://auth"))) @@ -30,27 +33,27 @@ class WebEidUriUtilTest { @Test fun isWebEidUri_appLinks_auth() { - assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/auth"))) + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("$appLinkHostUri/auth"))) } @Test fun isWebEidUri_appLinks_cert() { - assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/cert"))) + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("$appLinkHostUri/cert"))) } @Test fun isWebEidUri_appLinks_sign() { - assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/sign"))) + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("$appLinkHostUri/sign"))) } @Test fun isWebEidUri_appLinks_unknownOperation() { - assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/unknown"))) + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("$appLinkHostUri/unknown"))) } @Test fun isWebEidUri_wrongHost() { - assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://evil.com/auth"))) + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://example.org/auth"))) } @Test @@ -68,6 +71,48 @@ class WebEidUriUtilTest { assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("web-eid-mobile://unknown"))) } + @Test + fun browserPackageCandidate_applicationIdPreferredOverReferrer() { + assertEquals( + "com.example.browser", + WebEidUriUtil.browserPackageCandidate( + "com.example.browser", + Uri.parse("android-app://com.example.otherbrowser"), + ), + ) + } + + @Test + fun browserPackageCandidate_emptyApplicationIdFallsBackToReferrer() { + assertEquals( + "com.example.otherbrowser", + WebEidUriUtil.browserPackageCandidate("", Uri.parse("android-app://com.example.otherbrowser")), + ) + } + + @Test + fun browserPackageCandidate_androidAppReferrer() { + assertEquals( + "com.example.otherbrowser", + WebEidUriUtil.browserPackageCandidate(null, Uri.parse("android-app://com.example.otherbrowser")), + ) + } + + @Test + fun browserPackageCandidate_httpsReferrerIsNotAPackage() { + assertNull(WebEidUriUtil.browserPackageCandidate(null, Uri.parse("https://example.com/auth"))) + } + + @Test + fun browserPackageCandidate_referrerWithoutAuthority() { + assertNull(WebEidUriUtil.browserPackageCandidate(null, Uri.parse("android-app://"))) + } + + @Test + fun browserPackageCandidate_noApplicationIdAndNoReferrer() { + assertNull(WebEidUriUtil.browserPackageCandidate(null, null)) + } + @Test fun getOperation_customScheme_auth() { assertEquals(WebEidOperation.AUTH, WebEidUriUtil.getOperation(Uri.parse("web-eid-mobile://auth#dGVzdA"))) @@ -87,7 +132,7 @@ class WebEidUriUtilTest { fun getOperation_appLinks_auth() { assertEquals( WebEidOperation.AUTH, - WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/auth#dGVzdA")), + WebEidUriUtil.getOperation(Uri.parse("$appLinkHostUri/auth#dGVzdA")), ) } @@ -95,7 +140,7 @@ class WebEidUriUtilTest { fun getOperation_appLinks_cert() { assertEquals( WebEidOperation.CERT, - WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/cert#dGVzdA")), + WebEidUriUtil.getOperation(Uri.parse("$appLinkHostUri/cert#dGVzdA")), ) } @@ -103,7 +148,7 @@ class WebEidUriUtilTest { fun getOperation_appLinks_sign() { assertEquals( WebEidOperation.SIGN, - WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/sign#dGVzdA")), + WebEidUriUtil.getOperation(Uri.parse("$appLinkHostUri/sign#dGVzdA")), ) } @@ -114,7 +159,7 @@ class WebEidUriUtilTest { @Test fun getOperation_appLinks_unknownOperation_returnsNull() { - assertNull(WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/unknown"))) + assertNull(WebEidUriUtil.getOperation(Uri.parse("$appLinkHostUri/unknown"))) } @Test diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt index 634dc651a..22e752ab0 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -147,7 +147,7 @@ class WebEidViewModelTest { @Test fun webEidViewModel_secondResponseForSameFlow_isIgnored() { runTest(UnconfinedTestDispatcher()) { - val emitted = mutableListOf() + val emitted = mutableListOf() val job = launch { viewModel.relyingPartyResponseEvents.toList(emitted) } viewModel.handleAuth(Uri.parse(validAuthUri)) @@ -164,7 +164,7 @@ class WebEidViewModelTest { @Test fun webEidViewModel_handleAuth_permitsOneResponsePerRequest() { runTest(UnconfinedTestDispatcher()) { - val emitted = mutableListOf() + val emitted = mutableListOf() val job = launch { viewModel.relyingPartyResponseEvents.toList(emitted) } viewModel.handleAuth(Uri.parse(validAuthUri)) @@ -187,7 +187,7 @@ class WebEidViewModelTest { val received = viewModel.relyingPartyResponseEvents.first() assertNotNull(received) - assert(received.toString().startsWith("https://example.com/response#")) + assert(received.uri.toString().startsWith("https://example.com/response#")) } } @@ -238,7 +238,7 @@ class WebEidViewModelTest { viewModel.handleAuth(uri) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -263,7 +263,7 @@ class WebEidViewModelTest { viewModel.handleAuth(uri) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert( emittedUri.toString().startsWith( "https://example.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.com/response#", @@ -308,7 +308,7 @@ class WebEidViewModelTest { viewModel.handleWebEidAuthResult(cert, signingCert, signature) verify(authService).buildAuthToken(cert, signingCert, signature) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -340,7 +340,7 @@ class WebEidViewModelTest { viewModel.handleWebEidAuthResult(cert, signingCert, signature) verify(authService).buildAuthToken(cert, null, signature) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -372,7 +372,7 @@ class WebEidViewModelTest { viewModel.handleWebEidAuthResult(cert, signingCert, signature) verify(authService).buildAuthToken(cert, signingCert, signature) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -407,7 +407,7 @@ class WebEidViewModelTest { verify(authService).buildAuthToken(cert, null, signature) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) @@ -482,7 +482,7 @@ class WebEidViewModelTest { viewModel.handleSign(uri) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -540,7 +540,7 @@ class WebEidViewModelTest { viewModel.handleWebEidCertificateResult(signingCert) verify(signService).buildCertificatePayload(signingCert) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -572,7 +572,7 @@ class WebEidViewModelTest { viewModel.handleWebEidCertificateResult(signingCert) verify(signService).buildCertificatePayload(signingCert) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -603,7 +603,7 @@ class WebEidViewModelTest { viewModel.handleWebEidSignResult(signingCert, signature, responseUri) verify(signService).buildSignPayload(signingCert, signature, hashFunction) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -633,7 +633,7 @@ class WebEidViewModelTest { viewModel.handleWebEidSignResult(signingCert, signature, responseUri) verify(signService).buildSignPayload(signingCert, signature, hashFunction) - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) @@ -661,7 +661,7 @@ class WebEidViewModelTest { viewModel.handleUserCancelled() - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assertNotNull(emittedUri.fragment) @@ -692,7 +692,7 @@ class WebEidViewModelTest { viewModel.handleUserCancelled() - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://example.com/response#")) assertNotNull(emittedUri.fragment) @@ -720,7 +720,7 @@ class WebEidViewModelTest { viewModel.handleUserCancelled() - val emittedUri = deferred.await() + val emittedUri = deferred.await().uri assert(emittedUri.toString().startsWith("https://rp.example.com/sign/response#")) assertNotNull(emittedUri.fragment) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt index 37623453d..0eeef0425 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt @@ -8,7 +8,9 @@ package ee.ria.DigiDoc import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import android.provider.Browser import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -40,6 +42,7 @@ import ee.ria.DigiDoc.utilsLib.file.FileUtil.getExternalFileUris import ee.ria.DigiDoc.utilsLib.file.FileUtil.getLogsDirectory import ee.ria.DigiDoc.utilsLib.locale.LocaleUtil.getLocale import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import java.util.logging.Logger @@ -163,6 +166,16 @@ class MainActivity : isAppReady = true } + if (webEidUri != null) { + try { + logWebEidLaunchSource(intent, browserPackage) + } catch (ce: CancellationException) { + throw ce + } catch (t: Throwable) { + LoggingUtil.errorLog(logTag, "Unable to log Web eID launch source", t) + } + } + setContent { RIADigiDocTheme(darkTheme = useDarkMode) { RIADigiDocAppScreen( @@ -198,13 +211,11 @@ class MainActivity : private fun isDarkModeEnabled(dataStore: DataStore): Boolean = dataStore.getThemeSetting() == ThemeSetting.DARK private fun resolveBrowserPackage(intent: Intent): String? = - ( - intent - .getStringExtra("com.android.browser.application_id") - ?.takeIf { it.isNotEmpty() } - ?: ActivityCompat.getReferrer(this)?.host - ) // TODO: This needs testing with App Link - ?.takeIf { pkg -> + WebEidUriUtil + .browserPackageCandidate( + intent.getStringExtra(Browser.EXTRA_APPLICATION_ID), + ActivityCompat.getReferrer(this), + )?.takeIf { pkg -> val browseIntent = Intent(Intent.ACTION_VIEW, "https://".toUri()).apply { setPackage(pkg) @@ -215,4 +226,24 @@ class MainActivity : PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()), ) != null } + + private fun logWebEidLaunchSource( + intent: Intent, + browserPackage: String?, + ) { + val referrer = ActivityCompat.getReferrer(this) + val initialCallerPackage = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + initialCaller.getPackage() + } else { + null + } + debugLog( + logTag, + "Web eID launch source. Application id ${intent.getStringExtra(Browser.EXTRA_APPLICATION_ID)}, " + + "referrer scheme ${referrer?.scheme}, referrer authority ${referrer?.authority}, " + + "initial caller $initialCallerPackage, extra keys ${intent.extras?.keySet()}, " + + "resolved browser $browserPackage, task $taskId, task root $isTaskRoot", + ) + } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt index da7241a3a..f91f2c0f8 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -6,7 +6,9 @@ package ee.ria.DigiDoc.fragment import android.app.Activity +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri import androidx.activity.compose.LocalActivity @@ -16,24 +18,39 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import ee.ria.DigiDoc.R import ee.ria.DigiDoc.fragment.screen.WebEidScreen +import ee.ria.DigiDoc.ui.component.webeid.WebEidBrowser +import ee.ria.DigiDoc.ui.component.webeid.WebEidBrowserBottomSheet import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme +import ee.ria.DigiDoc.utils.WebEidBrowserCache import ee.ria.DigiDoc.utils.WebEidOperation import ee.ria.DigiDoc.utils.WebEidUriUtil +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.viewmodel.WebEidViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel +private const val LOG_TAG = "WebEidFragment" + @OptIn(ExperimentalComposeUiApi::class) @Composable fun WebEidFragment( @@ -47,18 +64,34 @@ fun WebEidFragment( sharedMenuViewModel: SharedMenuViewModel = hiltViewModel(), ) { val activity = LocalActivity.current as Activity + val context = LocalContext.current + val browserSelectionUri by viewModel.browserSelectionUri.collectAsState() LaunchedEffect(viewModel) { - viewModel.relyingPartyResponseEvents.collect { responseUri -> - val browserIntent = - Intent(Intent.ACTION_VIEW, responseUri).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - if (!browserPackage.isNullOrEmpty()) { - setPackage(browserPackage) - } - } - activity.startActivity(browserIntent) - activity.finishAndRemoveTask() + viewModel.relyingPartyResponseEvents.collect { response -> + val origin = viewModel.requestOrigin + val isCertificateStep = viewModel.certificateRequest.value != null + if (response.isCompletedOperation && + isCertificateStep && + origin != null && + !browserPackage.isNullOrEmpty() + ) { + WebEidBrowserCache.rememberResolved(origin, browserPackage) + } + val targetPackage = browserPackage ?: origin?.let { WebEidBrowserCache.recall(it) } + debugLog( + LOG_TAG, + "Web eID response. Origin $origin, resolved browser $browserPackage, " + + "target browser $targetPackage", + ) + val browsers = if (targetPackage == null) installedBrowsers(context) else emptyList() + if (browsers.size > 1) { + viewModel.requestBrowserSelection(response.uri) + return@collect + } + if (viewModel.startResponseDispatch()) { + sendResponse(activity, response.uri, targetPackage ?: browsers.firstOrNull()?.packageName) + } } } @@ -73,6 +106,37 @@ fun WebEidFragment( } } + browserSelectionUri?.let { responseUri -> + val browsers = remember { installedBrowsers(context) } + val isAuthentication = webEidUri?.let { WebEidUriUtil.getOperation(it) } == WebEidOperation.AUTH + WebEidBrowserBottomSheet( + browsers = browsers, + message = + if (isAuthentication) { + R.string.web_eid_select_browser_message_auth + } else { + R.string.web_eid_select_browser_message_signing + }, + onSelect = { selectedPackage -> + val selectedOrigin = viewModel.requestOrigin + val isCertificateRequest = viewModel.certificateRequest.value != null + val remembering = isCertificateRequest && selectedOrigin != null + debugLog( + LOG_TAG, + "Web eID response. Selected browser $selectedPackage, origin $selectedOrigin, " + + "is certificate request $isCertificateRequest, remembering $remembering", + ) + if (remembering && selectedOrigin != null) { + WebEidBrowserCache.rememberSelected(selectedOrigin, selectedPackage) + } + if (viewModel.startResponseDispatch()) { + sendResponse(activity, responseUri, selectedPackage) + } + }, + onDismiss = { activity.finishAndRemoveTask() }, + ) + } + Surface( modifier = modifier @@ -104,3 +168,64 @@ fun WebEidFragmentPreview() { ) } } + +private fun sendResponse( + activity: Activity, + responseUri: Uri, + browserPackage: String?, +) { + try { + activity.startActivity(responseIntent(responseUri, browserPackage)) + } catch (re: RuntimeException) { + errorLog(LOG_TAG, "Unable to open Web eID response in $browserPackage", re) + if (!browserPackage.isNullOrEmpty()) { + try { + activity.startActivity(responseIntent(responseUri, null)) + } catch (re2: RuntimeException) { + errorLog(LOG_TAG, "Unable to open Web eID response", re2) + } + } + } + activity.finishAndRemoveTask() +} + +private fun installedBrowsers(context: Context): List { + val packageManager = context.packageManager + val browseIntent = + Intent(Intent.ACTION_VIEW, "https://example.com".toUri()) + .addCategory(Intent.CATEGORY_BROWSABLE) + + @Suppress("QueryPermissionsNeeded") + return packageManager + .queryIntentActivities( + browseIntent, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()), + ).map { resolveInfo -> + val packageName = resolveInfo.activityInfo.packageName + WebEidBrowser( + packageName = packageName, + label = resolveInfo.loadLabel(packageManager).toString(), + icon = + try { + packageManager.getApplicationIcon(packageName).toBitmap().asImageBitmap() + } catch (nnfe: PackageManager.NameNotFoundException) { + errorLog(LOG_TAG, "Unable to load browser icon for $packageName", nnfe) + null + }, + ) + }.distinctBy { it.packageName } + .also { browsers -> + debugLog(LOG_TAG, "Web eID browsers ${browsers.map { it.packageName }}") + } +} + +private fun responseIntent( + responseUri: Uri, + browserPackage: String?, +): Intent = + Intent(Intent.ACTION_VIEW, responseUri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + if (!browserPackage.isNullOrEmpty()) { + setPackage(browserPackage) + } + } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowser.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowser.kt new file mode 100644 index 000000000..14bc86566 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowser.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.ui.component.webeid + +import androidx.compose.ui.graphics.ImageBitmap + +data class WebEidBrowser( + val packageName: String, + val label: String, + val icon: ImageBitmap?, +) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowserBottomSheet.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowserBottomSheet.kt new file mode 100644 index 000000000..a10d78a2b --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/webeid/WebEidBrowserBottomSheet.kt @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +@file:Suppress("PackageName", "FunctionName") + +package ee.ria.DigiDoc.ui.component.webeid + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding +import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding +import ee.ria.DigiDoc.ui.theme.Dimensions.iconSizeXXS +import ee.ria.DigiDoc.utils.extensions.notAccessible + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun WebEidBrowserBottomSheet( + modifier: Modifier = Modifier, + browsers: List, + @StringRes message: Int, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + modifier = + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(SPadding) + .semantics { + testTagsAsResourceId = true + }.testTag("webEidBrowserSheetContainer"), + ) { + Text( + modifier = + modifier + .padding(XSPadding) + .testTag("webEidBrowserSheetTitle") + .notAccessible(), + text = stringResource(R.string.web_eid_select_browser_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + modifier = modifier.padding(XSPadding).testTag("webEidBrowserSheetMessage"), + text = stringResource(message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = modifier.padding(XSPadding)) + browsers.forEach { browser -> + Row( + modifier = + modifier + .fillMaxWidth() + .clickable { onSelect(browser.packageName) } + .padding(XSPadding) + .testTag("webEidBrowserSheetItem"), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = browser.icon + if (icon != null) { + androidx.compose.foundation.Image( + bitmap = icon, + contentDescription = null, + modifier = modifier.padding(XSPadding).size(iconSizeXXS).notAccessible(), + ) + } else { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_m3_open_in_new_48dp_wght400), + contentDescription = null, + modifier = modifier.padding(XSPadding).size(iconSizeXXS).notAccessible(), + ) + } + Spacer(modifier = modifier.width(XSPadding)) + Text( + text = browser.label, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCache.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCache.kt new file mode 100644 index 000000000..16587baea --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCache.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.utils + +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +object WebEidBrowserCache { + private val RESOLVED_TTL = 30.seconds + private val SELECTED_TTL = 5.minutes + + private var origin: String? = null + private var packageName: String? = null + private var expiresAt = 0L + + fun rememberResolved( + origin: String, + browserPackage: String, + now: Long = System.currentTimeMillis(), + ) = remember(origin, browserPackage, now + RESOLVED_TTL.inWholeMilliseconds) + + fun rememberSelected( + origin: String, + browserPackage: String, + now: Long = System.currentTimeMillis(), + ) = remember(origin, browserPackage, now + SELECTED_TTL.inWholeMilliseconds) + + fun recall( + origin: String, + now: Long = System.currentTimeMillis(), + ): String? { + val remembered = packageName?.takeIf { this.origin == origin && now <= expiresAt } + clear() + return remembered + } + + private fun clear() { + origin = null + packageName = null + expiresAt = 0L + } + + private fun remember( + origin: String, + browserPackage: String, + expiry: Long, + ) { + this.origin = origin + packageName = browserPackage + expiresAt = expiry + } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt index 4a1441441..c1cb0767a 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt @@ -23,9 +23,17 @@ enum class WebEidOperation( object WebEidUriUtil { private const val CUSTOM_SCHEME = "web-eid-mobile" + private const val ANDROID_APP_SCHEME = "android-app" fun isWebEidUri(uri: Uri): Boolean = getOperation(uri) != null + fun browserPackageCandidate( + applicationId: String?, + referrer: Uri?, + ): String? = + applicationId?.takeIf { it.isNotEmpty() } + ?: referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority?.takeIf { it.isNotEmpty() } + fun getOperation(uri: Uri): WebEidOperation? { val operation = when { diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt index f2906b16e..1e7dfe7db 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -27,8 +27,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +data class WebEidResponse( + val uri: Uri, + val isCompletedOperation: Boolean, +) + @HiltViewModel class WebEidViewModel @Inject @@ -44,11 +50,12 @@ class WebEidViewModel val certificateRequest: StateFlow = _certificateRequest.asStateFlow() private val _signRequest = MutableStateFlow(null) val signRequest: StateFlow = _signRequest.asStateFlow() - private val _relyingPartyResponseEvents = Channel(Channel.BUFFERED) - val relyingPartyResponseEvents: Flow = _relyingPartyResponseEvents.receiveAsFlow() + private val _relyingPartyResponseEvents = Channel(Channel.BUFFERED) + val relyingPartyResponseEvents: Flow = _relyingPartyResponseEvents.receiveAsFlow() + private val hasDispatchedResponse = AtomicBoolean(false) private val _dialogError = MutableStateFlow(0) val dialogError: StateFlow = _dialogError - private var hasRespondedToRelyingParty = false + private val hasRespondedToRelyingParty = AtomicBoolean(false) private fun resetRequests() { debugLog( @@ -61,18 +68,42 @@ class WebEidViewModel _certificateRequest.value = null _signRequest.value = null _dialogError.value = 0 - hasRespondedToRelyingParty = false + hasRespondedToRelyingParty.set(false) } - private suspend fun sendResponse(responseUri: Uri) { - if (hasRespondedToRelyingParty) { + private suspend fun sendResponse( + responseUri: Uri, + isCompletedOperation: Boolean, + ) { + if (!hasRespondedToRelyingParty.compareAndSet(false, true)) { errorLog(logTag, "Ignoring duplicate response to relying party") return } - hasRespondedToRelyingParty = true - _relyingPartyResponseEvents.send(responseUri) + _relyingPartyResponseEvents.send(WebEidResponse(responseUri, isCompletedOperation)) + } + + private val _browserSelectionUri = MutableStateFlow(null) + val browserSelectionUri: StateFlow = _browserSelectionUri.asStateFlow() + + fun requestBrowserSelection(responseUri: Uri) { + _browserSelectionUri.value = responseUri } + fun startResponseDispatch(): Boolean { + if (!hasDispatchedResponse.compareAndSet(false, true)) { + errorLog(logTag, "Ignoring repeated Web eID response dispatch") + return false + } + _browserSelectionUri.value = null + return true + } + + val requestOrigin: String? + get() = + authRequest.value?.origin + ?: certificateRequest.value?.origin + ?: signRequest.value?.origin + suspend fun handleAuth(uri: Uri) { resetRequests() try { @@ -81,7 +112,7 @@ class WebEidViewModel errorLog(logTag, "Invalid Web eID authentication request: $uri", e) val errorPayload = WebEidResponseUtil.createErrorPayload(e.errorCode, e.message) val responseUri = WebEidResponseUtil.createResponseUri(e.responseUri, errorPayload) - sendResponse(responseUri) + sendResponse(responseUri, isCompletedOperation = false) } catch (e: Exception) { errorLog(logTag, "Unable parse Web eID authentication request: $uri", e) _dialogError.value = R.string.web_eid_invalid_auth_request_error @@ -106,7 +137,7 @@ class WebEidViewModel errorLog(logTag, "Invalid Web eID signing request: $uri", e) val errorPayload = WebEidResponseUtil.createErrorPayload(e.errorCode, e.message) val responseUri = WebEidResponseUtil.createResponseUri(e.responseUri, errorPayload) - sendResponse(responseUri) + sendResponse(responseUri, isCompletedOperation = false) } catch (e: Exception) { errorLog(logTag, "Unable parse Web eID signing request: $uri", e) _dialogError.value = R.string.web_eid_invalid_request_error @@ -136,7 +167,7 @@ class WebEidViewModel ) val payload = JSONObject().put("authToken", token) val responseUri = WebEidResponseUtil.createResponseUri(loginUri, payload) - sendResponse(responseUri) + sendResponse(responseUri, isCompletedOperation = true) } catch (e: Exception) { errorLog(logTag, "Unexpected error building auth token", e) val errorPayload = @@ -145,7 +176,7 @@ class WebEidViewModel "Unexpected error", ) val responseUri = WebEidResponseUtil.createResponseUri(loginUri, errorPayload) - sendResponse(responseUri) + sendResponse(responseUri, isCompletedOperation = false) } } @@ -160,7 +191,7 @@ class WebEidViewModel try { val payload = signService.buildCertificatePayload(signingCert) val response = WebEidResponseUtil.createResponseUri(responseUri, payload) - sendResponse(response) + sendResponse(response, isCompletedOperation = true) } catch (e: Exception) { errorLog(logTag, "Unexpected error building certificate payload", e) val errorPayload = @@ -169,7 +200,7 @@ class WebEidViewModel "Unexpected error", ) val errorUri = WebEidResponseUtil.createResponseUri(responseUri, errorPayload) - sendResponse(errorUri) + sendResponse(errorUri, isCompletedOperation = false) } } @@ -190,7 +221,7 @@ class WebEidViewModel hashFunction, ) val response = WebEidResponseUtil.createResponseUri(responseUri, payload) - sendResponse(response) + sendResponse(response, isCompletedOperation = true) } catch (e: Exception) { errorLog(logTag, "Unexpected error building sign payload", e) val errorPayload = @@ -199,7 +230,7 @@ class WebEidViewModel "Unexpected error", ) val errorUri = WebEidResponseUtil.createResponseUri(responseUri, errorPayload) - sendResponse(errorUri) + sendResponse(errorUri, isCompletedOperation = false) } } @@ -224,7 +255,7 @@ class WebEidViewModel val errorUri = WebEidResponseUtil.createResponseUri(responseUri, errorPayload) - sendResponse(errorUri) + sendResponse(errorUri, isCompletedOperation = false) } catch (e: Exception) { errorLog(logTag, "Failed to send cancel response", e) } diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 046192a98..25baf8d60 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -729,6 +729,9 @@ Allkirjasta ID-kaardiga Sertifikaadipäring: Allkirjastamispäring: + Vali veebilehitseja + Vali veebilehitseja, kus alustasid autentimist. + Vali veebilehitseja, kus alustasid allkirjastamist. Tühista Vigane Web eID päring Päringu viga diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a81b9590..a41d9f0b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -729,6 +729,9 @@ Sign with ID-card Certificate request from: Signing request from: + Select browser + Select the browser where you started the authentication. + Select the browser where you started signing. Cancel Invalid Web eID request Request error diff --git a/app/src/test/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCacheTest.kt b/app/src/test/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCacheTest.kt new file mode 100644 index 000000000..0738f5f2d --- /dev/null +++ b/app/src/test/kotlin/ee/ria/DigiDoc/utils/WebEidBrowserCacheTest.kt @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WebEidBrowserCacheTest { + private val resolvedTtlMillis = 30 * 1000L + private val selectedTtlMillis = 5 * 60 * 1000L + private val origin = "https://example.com" + + @Test + fun recall_returnsResolvedPackageWithinTtl() { + WebEidBrowserCache.rememberResolved(origin, "com.example.browser", now = 1_000L) + + assertEquals( + "com.example.browser", + WebEidBrowserCache.recall(origin, now = 1_000L + resolvedTtlMillis), + ) + } + + @Test + fun recall_returnsNullAfterResolvedTtl() { + WebEidBrowserCache.rememberResolved(origin, "com.example.browser", now = 1_000L) + + assertNull(WebEidBrowserCache.recall(origin, now = 1_000L + resolvedTtlMillis + 1)) + } + + @Test + fun recall_returnsSelectedPackageAfterResolvedTtl() { + WebEidBrowserCache.rememberSelected(origin, "com.example.browser", now = 1_000L) + + assertEquals( + "com.example.browser", + WebEidBrowserCache.recall(origin, now = 1_000L + resolvedTtlMillis + 1), + ) + } + + @Test + fun recall_returnsNullAfterSelectedTtl() { + WebEidBrowserCache.rememberSelected(origin, "com.example.browser", now = 1_000L) + + assertNull(WebEidBrowserCache.recall(origin, now = 1_000L + selectedTtlMillis + 1)) + } + + @Test + fun recall_returnsNullOnSecondCall() { + WebEidBrowserCache.rememberSelected(origin, "com.example.browser", now = 1_000L) + + assertEquals("com.example.browser", WebEidBrowserCache.recall(origin, now = 1_000L)) + assertNull(WebEidBrowserCache.recall(origin, now = 1_000L)) + } + + @Test + fun recall_returnsNullForAnotherOrigin() { + WebEidBrowserCache.rememberSelected(origin, "com.example.browser", now = 1_000L) + + assertNull(WebEidBrowserCache.recall("https://other.example", now = 1_000L)) + } + + @Test + fun recall_returnsLatestRememberedPackage() { + WebEidBrowserCache.rememberSelected(origin, "com.example.browser", now = 1_000L) + WebEidBrowserCache.rememberResolved(origin, "com.example.otherbrowser", now = 2_000L) + + assertEquals("com.example.otherbrowser", WebEidBrowserCache.recall(origin, now = 2_000L)) + } +}