[PM-24411] Generalize IntentManager activity handling (#5689)

This commit is contained in:
Patrick Honkonen 2025-08-13 18:03:09 -04:00 committed by GitHub
parent a688693f43
commit 26252ebcdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 173 additions and 108 deletions

View File

@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -50,6 +51,7 @@ import com.x8bit.bitwarden.ui.platform.components.image.BitwardenGifImage
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startSystemAutofillSettingsActivity
/**
* Top level composable for the Auto-fill setup screen.
@ -62,12 +64,15 @@ fun SetupAutoFillScreen(
intentManager: IntentManager = LocalIntentManager.current,
viewModel: SetupAutoFillViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handler = rememberSetupAutoFillHandler(viewModel = viewModel)
EventsEffect(viewModel = viewModel) { event ->
when (event) {
SetupAutoFillEvent.NavigateToAutofillSettings -> {
val showFallback = !intentManager.startSystemAutofillSettingsActivity()
val showFallback = !intentManager.startSystemAutofillSettingsActivity(
context = context,
)
if (showFallback) {
handler.sendAutoFillServiceFallback.invoke()
}

View File

@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.auth.feature.checkemail.handlers.rememberCheckEmai
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startDefaultEmailApplication
/**
* Top level composable for the check email screen.

View File

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import android.content.Context
import android.content.res.Resources
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
@ -68,6 +69,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricSupportStatus
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startApplicationDetailsSettingsActivity
import com.x8bit.bitwarden.ui.platform.util.displayLabel
import com.x8bit.bitwarden.ui.platform.util.minutes
import kotlinx.collections.immutable.toImmutableList
@ -91,6 +93,7 @@ fun AccountSecurityScreen(
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
intentManager: IntentManager = LocalIntentManager.current,
) {
val context: Context = LocalContext.current
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) }
val unlockWithBiometricToggle: (cipher: Cipher) -> Unit = remember(viewModel) {
@ -105,7 +108,7 @@ fun AccountSecurityScreen(
AccountSecurityEvent.NavigateBack -> onNavigateBack()
AccountSecurityEvent.NavigateToApplicationDataSettings -> {
intentManager.startApplicationDetailsSettingsActivity()
intentManager.startApplicationDetailsSettingsActivity(context = context)
}
AccountSecurityEvent.NavigateToDeleteAccount -> onNavigateToDeleteAccount()

View File

@ -61,6 +61,9 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.handlers.AutoFi
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.displayLabel
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.isAdvancedMatching
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startBrowserAutofillSettingsActivity
import com.x8bit.bitwarden.ui.platform.manager.utils.startSystemAccessibilitySettingsActivity
import com.x8bit.bitwarden.ui.platform.manager.utils.startSystemAutofillSettingsActivity
import kotlinx.collections.immutable.toImmutableList
/**
@ -90,7 +93,9 @@ fun AutoFillScreen(
}
AutoFillEvent.NavigateToAutofillSettings -> {
val isSuccess = intentManager.startSystemAutofillSettingsActivity()
val isSuccess = intentManager.startSystemAutofillSettingsActivity(
context = context,
)
shouldShowAutofillFallbackDialog = !isSuccess
}

View File

@ -8,9 +8,11 @@ import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.result.ActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.ui.platform.model.FileData
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
import kotlinx.parcelize.Parcelize
import java.time.Clock
/**
* A manager class for simplifying the handling of Android Intents within a given context.
@ -20,40 +22,21 @@ import kotlinx.parcelize.Parcelize
interface IntentManager {
/**
* Start an activity using the provided [Intent].
*
* @return `true` if the activity was started successfully, `false` otherwise.
*/
fun startActivity(intent: Intent)
fun startActivity(intent: Intent): Boolean
/**
* Start a Custom Tabs Activity using the provided [Uri].
*/
fun startCustomTabsActivity(uri: Uri)
/**
* Attempts to start the system accessibility settings activity.
*/
fun startSystemAccessibilitySettingsActivity()
/**
* Attempts to start the system autofill settings activity. The return value indicates whether
* or not this was successful.
*/
fun startSystemAutofillSettingsActivity(): Boolean
/**
* Starts the application's settings activity.
*/
fun startApplicationDetailsSettingsActivity()
/**
* Starts the credential manager settings.
*/
fun startCredentialManagerSettings(context: Context)
/**
* Starts the browser autofill settings activity for the provided [BrowserPackage].
*/
fun startBrowserAutofillSettingsActivity(browserPackage: BrowserPackage): Boolean
/**
* Start an activity to view the given [uri] in an external browser.
*/
@ -107,11 +90,6 @@ interface IntentManager {
*/
fun createDocumentIntent(fileName: String): Intent
/**
* Open the default email app on device.
*/
fun startDefaultEmailApplication()
/**
* Represents data for a share request coming from outside the app.
*/
@ -133,4 +111,21 @@ interface IntentManager {
val fileData: FileData,
) : ShareData()
}
@Suppress("UndocumentedPublicClass")
@OmitFromCoverage
companion object {
/**
* Creates a new [IntentManager] instance.
*/
fun create(
context: Context,
clock: Clock,
buildInfoManager: BuildInfoManager,
): IntentManager = IntentManagerImpl(
context = context,
clock = clock,
buildInfoManager = buildInfoManager,
)
}
}

View File

@ -10,7 +10,6 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.Settings
import android.webkit.MimeTypeMap
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
@ -30,7 +29,6 @@ import com.bitwarden.ui.platform.manager.util.deviceData
import com.bitwarden.ui.platform.manager.util.fileProviderAuthority
import com.bitwarden.ui.platform.model.FileData
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
import java.io.File
import java.time.Clock
@ -55,12 +53,11 @@ class IntentManagerImpl(
private val clock: Clock,
private val buildInfoManager: BuildInfoManager,
) : IntentManager {
override fun startActivity(intent: Intent) {
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
// no-op
}
override fun startActivity(intent: Intent): Boolean = try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
@Composable
@ -79,50 +76,12 @@ class IntentManagerImpl(
.launchUrl(context, uri)
}
override fun startSystemAccessibilitySettingsActivity() {
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
override fun startSystemAutofillSettingsActivity(): Boolean =
try {
val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE)
.apply {
data = "package:${context.packageName}".toUri()
}
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
override fun startApplicationDetailsSettingsActivity() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = "package:${context.packageName}".toUri()
startActivity(intent = intent)
}
override fun startCredentialManagerSettings(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
CredentialManager.create(context).createSettingsPendingIntent().send()
}
}
override fun startBrowserAutofillSettingsActivity(
browserPackage: BrowserPackage,
): Boolean = try {
val intent = Intent(Intent.ACTION_APPLICATION_PREFERENCES)
.apply {
addCategory(Intent.CATEGORY_DEFAULT)
addCategory(Intent.CATEGORY_APP_BROWSER)
addCategory(Intent.CATEGORY_PREFERENCE)
setPackage(browserPackage.packageName)
}
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
override fun launchUri(uri: Uri) {
if (uri.scheme.equals(other = "androidapp", ignoreCase = true)) {
val packageName = uri.toString().removePrefix(prefix = "androidapp://")
@ -271,13 +230,6 @@ class IntentManagerImpl(
putExtra(Intent.EXTRA_TITLE, fileName)
}
override fun startDefaultEmailApplication() {
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_APP_EMAIL)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun createPlayStoreIntent(packageName: String): Intent {
val playStoreUri = "https://play.google.com/store/apps/details"
.toUri()

View File

@ -0,0 +1,69 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.manager.utils
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Starts the system autofill settings activity.
*
* @param context The context from which to start the activity.
* @return `true` if the activity was started successfully, `false` otherwise.
*/
fun IntentManager.startSystemAutofillSettingsActivity(
context: Context,
): Boolean = !startActivity(
intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE)
.setData("package:${context.packageName}".toUri()),
)
/**
* Attempts to start the system accessibility settings activity.
*/
fun IntentManager.startSystemAccessibilitySettingsActivity() {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
/**
* Starts the browser autofill settings activity for the provided [browserPackage].
*/
fun IntentManager.startBrowserAutofillSettingsActivity(
browserPackage: BrowserPackage,
): Boolean = try {
val intent = Intent(Intent.ACTION_APPLICATION_PREFERENCES)
.apply {
addCategory(Intent.CATEGORY_DEFAULT)
addCategory(Intent.CATEGORY_APP_BROWSER)
addCategory(Intent.CATEGORY_PREFERENCE)
setPackage(browserPackage.packageName)
}
startActivity(intent)
} catch (_: ActivityNotFoundException) {
false
}
/**
* Starts the application's settings activity.
*/
fun IntentManager.startApplicationDetailsSettingsActivity(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = "package:${context.packageName}".toUri()
startActivity(intent = intent)
}
/**
* Open the default email app on device.
*/
fun IntentManager.startDefaultEmailApplication() {
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_APP_EMAIL)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}

View File

@ -13,8 +13,10 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startSystemAutofillSettingsActivity
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
@ -106,19 +108,23 @@ class SetupAutofillScreenTest : BitwardenComposeTest() {
@Test
fun `NavigateToAutoFillSettings should start system autofill settings activity`() {
every { intentManager.startSystemAutofillSettingsActivity() } returns true
mutableEventFlow.tryEmit(SetupAutoFillEvent.NavigateToAutofillSettings)
verify {
intentManager.startSystemAutofillSettingsActivity()
mockkStatic(IntentManager::startSystemAutofillSettingsActivity) {
every { intentManager.startSystemAutofillSettingsActivity(any()) } returns true
mutableEventFlow.tryEmit(SetupAutoFillEvent.NavigateToAutofillSettings)
verify {
intentManager.startSystemAutofillSettingsActivity(any())
}
}
}
@Suppress("MaxLineLength")
@Test
fun `NavigateToAutoFillSettings should send AutoFillServiceFallback action when intent fails`() {
every { intentManager.startSystemAutofillSettingsActivity() } returns false
mutableEventFlow.tryEmit(SetupAutoFillEvent.NavigateToAutofillSettings)
verify { viewModel.trySendAction(SetupAutoFillAction.AutoFillServiceFallback) }
mockkStatic(IntentManager::startSystemAutofillSettingsActivity) {
every { intentManager.startSystemAutofillSettingsActivity(any()) } returns false
mutableEventFlow.tryEmit(SetupAutoFillEvent.NavigateToAutofillSettings)
verify { viewModel.trySendAction(SetupAutoFillAction.AutoFillServiceFallback) }
}
}
@Test

View File

@ -7,9 +7,11 @@ import androidx.compose.ui.test.performScrollTo
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startDefaultEmailApplication
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
@ -18,9 +20,7 @@ import org.junit.Before
import org.junit.Test
class CheckEmailScreenTest : BitwardenComposeTest() {
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { startDefaultEmailApplication() } just runs
}
private val intentManager = mockk<IntentManager>(relaxed = true)
private var onNavigateBackCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -72,9 +72,12 @@ class CheckEmailScreenTest : BitwardenComposeTest() {
@Test
fun `NavigateToEmailApp should call openEmailApp`() {
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateToEmailApp)
verify {
intentManager.startDefaultEmailApplication()
mockkStatic(IntentManager::startDefaultEmailApplication) {
every { intentManager.startDefaultEmailApplication() } just runs
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateToEmailApp)
verify {
intentManager.startDefaultEmailApplication()
}
}
}

View File

@ -39,7 +39,7 @@ class StartRegistrationScreenTest : BitwardenComposeTest() {
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { startCustomTabsActivity(any()) } just runs
every { startActivity(any()) } just runs
every { startActivity(any()) } returns true
every { launchUri(any()) } just runs
}

View File

@ -29,10 +29,12 @@ import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricSupportStatus
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startApplicationDetailsSettingsActivity
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
@ -53,8 +55,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
private val intentManager = mockk<IntentManager> {
every { launchUri(any()) } just runs
every { startActivity(any()) } just runs
every { startApplicationDetailsSettingsActivity() } just runs
every { startActivity(any()) } returns true
}
private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>()
private val captureBiometricsCancel = slot<() -> Unit>()
@ -104,9 +105,11 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
@Test
fun `on NavigateToApplicationDataSettings should launch the correct intent`() {
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToApplicationDataSettings)
verify { intentManager.startApplicationDetailsSettingsActivity() }
mockkStatic(IntentManager::startApplicationDetailsSettingsActivity) {
every { intentManager.startApplicationDetailsSettingsActivity(any()) } just runs
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToApplicationDataSettings)
verify { intentManager.startApplicationDetailsSettingsActivity(any()) }
}
}
@Test

View File

@ -20,14 +20,20 @@ import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.model.BrowserAutofillSettingsOption
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startBrowserAutofillSettingsActivity
import com.x8bit.bitwarden.ui.platform.manager.utils.startSystemAccessibilitySettingsActivity
import com.x8bit.bitwarden.ui.platform.manager.utils.startSystemAutofillSettingsActivity
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -49,15 +55,23 @@ class AutoFillScreenTest : BitwardenComposeTest() {
every { stateFlow } returns mutableStateFlow
}
private val intentManager: IntentManager = mockk {
every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess }
every { startCredentialManagerSettings(any()) } just runs
every { startSystemAccessibilitySettingsActivity() } just runs
every { launchUri(any()) } just runs
every { startBrowserAutofillSettingsActivity(any()) } returns true
}
@Before
fun setUp() {
mockkStatic(
IntentManager::startSystemAutofillSettingsActivity,
IntentManager::startSystemAccessibilitySettingsActivity,
IntentManager::startBrowserAutofillSettingsActivity,
)
every { intentManager.startBrowserAutofillSettingsActivity(any()) } returns true
every {
intentManager.startSystemAutofillSettingsActivity(any())
} answers { isSystemSettingsRequestSuccess }
every { intentManager.startSystemAccessibilitySettingsActivity() } just runs
setContent(
intentManager = intentManager,
) {
@ -76,6 +90,15 @@ class AutoFillScreenTest : BitwardenComposeTest() {
}
}
@After
fun tearDown() {
unmockkStatic(
IntentManager::startSystemAutofillSettingsActivity,
IntentManager::startSystemAccessibilitySettingsActivity,
IntentManager::startBrowserAutofillSettingsActivity,
)
}
@Test
fun `on NavigateToAccessibilitySettings should attempt to navigate to system settings`() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateToAccessibilitySettings)
@ -93,7 +116,7 @@ class AutoFillScreenTest : BitwardenComposeTest() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateToAutofillSettings)
verify {
intentManager.startSystemAutofillSettingsActivity()
intentManager.startSystemAutofillSettingsActivity(any())
}
composeTestRule.assertNoDialogExists()
}
@ -106,7 +129,7 @@ class AutoFillScreenTest : BitwardenComposeTest() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateToAutofillSettings)
verify {
intentManager.startSystemAutofillSettingsActivity()
intentManager.startSystemAutofillSettingsActivity(any())
}
composeTestRule