BIT-1076 Requesting Camera Permission (#415)

This commit is contained in:
Oleg Semenenko 2023-12-19 13:27:35 -06:00 committed by Álison Fernandes
parent f2f3a6a386
commit 39e285fff8
11 changed files with 247 additions and 19 deletions

View file

@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<application <application
android:name=".BitwardenApplication" android:name=".BitwardenApplication"
android:allowBackup="true" android:allowBackup="true"

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
/**
* Interface for managing permissions.
*/
@Immutable
interface PermissionsManager {
/**
* Method for creating and returning a permission launcher.
*/
@Composable
fun getLauncher(onResult: (Boolean) -> Unit): ManagedActivityResultLauncher<String, Boolean>
/**
* Method for checking whether the permission is granted.
*/
fun checkPermission(permission: String): Boolean
/**
* Method for checking if an informative UI should be shown the user.
*/
fun shouldShouldRequestPermissionRationale(
permission: String,
): Boolean
}

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.ui.platform.base.util
import android.app.Activity
import android.content.pm.PackageManager
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
/**
* Primary implementation of [PermissionsManager].
*/
class PermissionsManagerImpl(
private val activity: Activity,
) : PermissionsManager {
@Composable
override fun getLauncher(
onResult: (Boolean) -> Unit,
): ManagedActivityResultLauncher<String, Boolean> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult,
)
override fun checkPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(
activity,
permission,
) == PackageManager.PERMISSION_GRANTED
override fun shouldShouldRequestPermissionRationale(
permission: String,
): Boolean =
activity.shouldShowRequestPermissionRationale(permission)
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.additem package com.x8bit.bitwarden.ui.vault.feature.additem
import android.Manifest
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -11,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.PermissionsManager
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -19,6 +21,7 @@ import kotlinx.collections.immutable.toImmutableList
* The top level content UI state for the [VaultAddItemScreen]. * The top level content UI state for the [VaultAddItemScreen].
*/ */
@Composable @Composable
@Suppress("LongMethod")
fun AddEditItemContent( fun AddEditItemContent(
state: VaultAddItemState.ViewState.Content, state: VaultAddItemState.ViewState.Content,
isAddItemMode: Boolean, isAddItemMode: Boolean,
@ -26,7 +29,23 @@ fun AddEditItemContent(
commonTypeHandlers: VaultAddItemCommonTypeHandlers, commonTypeHandlers: VaultAddItemCommonTypeHandlers,
loginItemTypeHandlers: VaultAddLoginItemTypeHandlers, loginItemTypeHandlers: VaultAddLoginItemTypeHandlers,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
permissionsManager: PermissionsManager,
) { ) {
val launcher = permissionsManager.getLauncher(
onResult = { isGranted ->
when (state.type) {
is VaultAddItemState.ViewState.Content.ItemType.SecureNotes -> Unit
// TODO: Create UI for card-type item creation BIT-507
is VaultAddItemState.ViewState.Content.ItemType.Card -> Unit
// TODO: Create UI for identity-type item creation BIT-667
is VaultAddItemState.ViewState.Content.ItemType.Identity -> Unit
is VaultAddItemState.ViewState.Content.ItemType.Login -> {
loginItemTypeHandlers.onSetupTotpClick(isGranted)
}
}
},
)
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
) { ) {
@ -57,6 +76,13 @@ fun AddEditItemContent(
isAddItemMode = isAddItemMode, isAddItemMode = isAddItemMode,
commonActionHandler = commonTypeHandlers, commonActionHandler = commonTypeHandlers,
loginItemTypeHandlers = loginItemTypeHandlers, loginItemTypeHandlers = loginItemTypeHandlers,
onTotpSetupClick = {
if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) {
loginItemTypeHandlers.onSetupTotpClick(true)
} else {
launcher.launch(Manifest.permission.CAMERA)
}
},
) )
} }

View file

@ -32,13 +32,14 @@ import kotlinx.collections.immutable.toImmutableList
/** /**
* The UI for adding and editing a login cipher. * The UI for adding and editing a login cipher.
*/ */
@Suppress("LongMethod") @Suppress("LongMethod", "LongParameterList")
fun LazyListScope.addEditLoginItems( fun LazyListScope.addEditLoginItems(
commonState: VaultAddItemState.ViewState.Content.Common, commonState: VaultAddItemState.ViewState.Content.Common,
loginState: VaultAddItemState.ViewState.Content.ItemType.Login, loginState: VaultAddItemState.ViewState.Content.ItemType.Login,
isAddItemMode: Boolean, isAddItemMode: Boolean,
commonActionHandler: VaultAddItemCommonTypeHandlers, commonActionHandler: VaultAddItemCommonTypeHandlers,
loginItemTypeHandlers: VaultAddLoginItemTypeHandlers, loginItemTypeHandlers: VaultAddLoginItemTypeHandlers,
onTotpSetupClick: () -> Unit,
) { ) {
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -112,7 +113,7 @@ fun LazyListScope.addEditLoginItems(
BitwardenFilledTonalButtonWithIcon( BitwardenFilledTonalButtonWithIcon(
label = stringResource(id = R.string.setup_totp), label = stringResource(id = R.string.setup_totp),
icon = painterResource(id = R.drawable.ic_light_bulb), icon = painterResource(id = R.drawable.ic_light_bulb),
onClick = loginItemTypeHandlers.onSetupTotpClick, onClick = onTotpSetupClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.additem package com.x8bit.bitwarden.ui.vault.feature.additem
import android.app.Activity
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
@ -27,6 +28,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.base.util.PermissionsManager
import com.x8bit.bitwarden.ui.platform.base.util.PermissionsManagerImpl
/** /**
* Top level composable for the vault add item screen. * Top level composable for the vault add item screen.
@ -37,6 +40,8 @@ import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
fun VaultAddItemScreen( fun VaultAddItemScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
viewModel: VaultAddItemViewModel = hiltViewModel(), viewModel: VaultAddItemViewModel = hiltViewModel(),
permissionsManager: PermissionsManager =
PermissionsManagerImpl(LocalContext.current as Activity),
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
@ -101,6 +106,7 @@ fun VaultAddItemScreen(
}, },
loginItemTypeHandlers = loginItemTypeHandlers, loginItemTypeHandlers = loginItemTypeHandlers,
commonTypeHandlers = commonTypeHandlers, commonTypeHandlers = commonTypeHandlers,
permissionsManager = permissionsManager,
modifier = Modifier modifier = Modifier
.imePadding() .imePadding()
.padding(innerPadding) .padding(innerPadding)

View file

@ -15,8 +15,8 @@ import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
import com.x8bit.bitwarden.ui.vault.feature.additem.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.additem.model.toCustomField import com.x8bit.bitwarden.ui.vault.feature.additem.model.toCustomField
import com.x8bit.bitwarden.ui.vault.feature.additem.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
@ -332,7 +332,7 @@ class VaultAddItemViewModel @Inject constructor(
} }
is VaultAddItemAction.ItemType.LoginType.SetupTotpClick -> { is VaultAddItemAction.ItemType.LoginType.SetupTotpClick -> {
handleLoginSetupTotpClick() handleLoginSetupTotpClick(action)
} }
is VaultAddItemAction.ItemType.LoginType.UriSettingsClick -> { is VaultAddItemAction.ItemType.LoginType.UriSettingsClick -> {
@ -399,11 +399,18 @@ class VaultAddItemViewModel @Inject constructor(
} }
} }
private fun handleLoginSetupTotpClick() { private fun handleLoginSetupTotpClick(
action: VaultAddItemAction.ItemType.LoginType.SetupTotpClick,
) {
viewModelScope.launch { viewModelScope.launch {
val message = if (action.isGranted) {
"Permission Granted, QR Code Scanner Not Implemented"
} else {
"Permission Not Granted, Manual QR Code Entry Not Implemented"
}
sendEvent( sendEvent(
event = VaultAddItemEvent.ShowToast( event = VaultAddItemEvent.ShowToast(
message = "Setup TOTP", message = message,
), ),
) )
} }
@ -972,6 +979,13 @@ sealed class VaultAddItemAction {
*/ */
data class UriTextChange(val uri: String) : LoginType() data class UriTextChange(val uri: String) : LoginType()
/**
* Represents the action to set up TOTP.
*
* @property isGranted the status of the camera permission
*/
data class SetupTotpClick(val isGranted: Boolean) : LoginType()
/** /**
* Represents the action to open the username generator. * Represents the action to open the username generator.
*/ */
@ -987,11 +1001,6 @@ sealed class VaultAddItemAction {
*/ */
data object OpenPasswordGeneratorClick : LoginType() data object OpenPasswordGeneratorClick : LoginType()
/**
* Represents the action to set up TOTP.
*/
data object SetupTotpClick : LoginType()
/** /**
* Represents the action of clicking TOTP settings * Represents the action of clicking TOTP settings
*/ */

View file

@ -26,7 +26,7 @@ class VaultAddLoginItemTypeHandlers(
val onOpenUsernameGeneratorClick: () -> Unit, val onOpenUsernameGeneratorClick: () -> Unit,
val onPasswordCheckerClick: () -> Unit, val onPasswordCheckerClick: () -> Unit,
val onOpenPasswordGeneratorClick: () -> Unit, val onOpenPasswordGeneratorClick: () -> Unit,
val onSetupTotpClick: () -> Unit, val onSetupTotpClick: (Boolean) -> Unit,
val onUriSettingsClick: () -> Unit, val onUriSettingsClick: () -> Unit,
val onAddNewUriClick: () -> Unit, val onAddNewUriClick: () -> Unit,
) { ) {
@ -72,8 +72,10 @@ class VaultAddLoginItemTypeHandlers(
VaultAddItemAction.ItemType.LoginType.OpenPasswordGeneratorClick, VaultAddItemAction.ItemType.LoginType.OpenPasswordGeneratorClick,
) )
}, },
onSetupTotpClick = { onSetupTotpClick = { isGranted ->
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.SetupTotpClick) viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(isGranted),
)
}, },
onUriSettingsClick = { onUriSettingsClick = {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.UriSettingsClick) viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.UriSettingsClick)

View file

@ -0,0 +1,46 @@
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.compose.runtime.Composable
import io.mockk.every
import io.mockk.mockk
/**
* A helper class used to test permissions
*/
class FakePermissionManager : PermissionsManager {
/**
* The value returned when we check if we have the permission.
*/
var checkPermissionResult: Boolean = false
/**
* The value returned when the user is asked for permission.
*/
var getPermissionsResult: Boolean = false
/**
* * The value for whether a rationale should be shown to the user.
*/
var shouldShowRequestRationale: Boolean = false
@Composable
override fun getLauncher(
onResult: (Boolean) -> Unit,
): ManagedActivityResultLauncher<String, Boolean> {
return mockk {
every { launch(any()) } answers { onResult.invoke(getPermissionsResult) }
}
}
override fun checkPermission(permission: String): Boolean {
return checkPermissionResult
}
override fun shouldShouldRequestPermissionRationale(
permission: String,
): Boolean {
return shouldShowRequestRationale
}
}

View file

@ -30,6 +30,7 @@ import com.x8bit.bitwarden.ui.util.onAllNodesWithTextAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
import com.x8bit.bitwarden.ui.platform.base.util.FakePermissionManager
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -49,6 +50,8 @@ class VaultAddItemScreenTest : BaseComposeTest() {
private val mutableEventFlow = MutableSharedFlow<VaultAddItemEvent>(Int.MAX_VALUE) private val mutableEventFlow = MutableSharedFlow<VaultAddItemEvent>(Int.MAX_VALUE)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_LOGIN) private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_LOGIN)
private val fakePermissionManager: FakePermissionManager = FakePermissionManager()
private val viewModel = mockk<VaultAddItemViewModel>(relaxed = true) { private val viewModel = mockk<VaultAddItemViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow every { stateFlow } returns mutableStateFlow
@ -60,6 +63,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
VaultAddItemScreen( VaultAddItemScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true }, onNavigateBack = { onNavigateBackCalled = true },
permissionsManager = fakePermissionManager,
) )
} }
} }
@ -315,15 +319,52 @@ class VaultAddItemScreenTest : BaseComposeTest() {
.assertTextContains("•••••••••••") .assertTextContains("•••••••••••")
} }
@Suppress("MaxLineLength")
@Test @Test
fun `in ItemType_Login state clicking Set up TOTP button should trigger SetupTotpClick`() { fun `in ItemType_Login state clicking SetupTOTP button with a positive result should send true if permission check returns true`() {
fakePermissionManager.checkPermissionResult = true
composeTestRule composeTestRule
.onNodeWithTextAfterScroll(text = "Set up TOTP") .onNodeWithTextAfterScroll(text = "Set up TOTP")
.performClick() .performClick()
verify { verify {
viewModel.trySendAction( viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick, VaultAddItemAction.ItemType.LoginType.SetupTotpClick(true),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking SetupTOTP button with a positive result should send true`() {
fakePermissionManager.checkPermissionResult = false
fakePermissionManager.getPermissionsResult = true
composeTestRule
.onNodeWithTextAfterScroll(text = "Set up TOTP")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(true),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking Set up TOTP button with a negative result should send false`() {
fakePermissionManager.checkPermissionResult = false
fakePermissionManager.getPermissionsResult = false
composeTestRule
.onNodeWithTextAfterScroll(text = "Set up TOTP")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(false),
) )
} }
} }

View file

@ -474,12 +474,37 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `SetupTotpClick should emit ShowToast with 'Setup TOTP' message`() = runTest { fun `SetupTotpClick should emit ShowToast with permission granted when isGranted is true`() = runTest {
val viewModel = createAddVaultItemViewModel() val viewModel = createAddVaultItemViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.ItemType.LoginType.SetupTotpClick) viewModel.actionChannel.trySend(
assertEquals(VaultAddItemEvent.ShowToast("Setup TOTP"), awaitItem()) VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
true,
),
)
assertEquals(
VaultAddItemEvent.ShowToast("Permission Granted, QR Code Scanner Not Implemented"),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `SetupTotpClick should emit ShowToast with permission not granted when isGranted is false`() = runTest {
val viewModel = createAddVaultItemViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
false,
),
)
assertEquals(
VaultAddItemEvent.ShowToast("Permission Not Granted, Manual QR Code Entry Not Implemented"),
awaitItem(),
)
} }
} }