Request Camera permission before launching the external Camera app (#1395)

This commit is contained in:
Benoit Marty 2023-09-21 10:54:02 +02:00
parent ebfacdeeb8
commit da61b8970a
21 changed files with 124 additions and 20 deletions

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.deeplink) implementation(projects.libraries.deeplink)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
@ -64,6 +65,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -40,6 +40,8 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -52,10 +54,14 @@ class ConfigureRoomPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<ConfigureRoomState> { ) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
@ -93,7 +99,11 @@ class ConfigureRoomPresenter @Inject constructor(
is ConfigureRoomEvents.HandleAvatarAction -> { is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
cameraPermissionState.eventSink(PermissionsEvents.OpenSystemDialog)
}
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
} }
} }
@ -106,6 +116,7 @@ class ConfigureRoomPresenter @Inject constructor(
config = createRoomConfig.value, config = createRoomConfig.value,
avatarActions = avatarActions, avatarActions = avatarActions,
createRoomAction = createRoomAction.value, createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -20,12 +20,14 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState( data class ConfigureRoomState(
val config: CreateRoomConfig, val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: Async<RoomId>, val createRoomAction: Async<RoomId>,
val cameraPermissionState: PermissionsState,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) { ) {
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not() val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
@ -41,5 +42,6 @@ fun aConfigureRoomState() = ConfigureRoomState(
config = CreateRoomConfig(), config = CreateRoomConfig(),
avatarActions = persistentListOf(), avatarActions = persistentListOf(),
createRoomAction = Async.Uninitialized, createRoomAction = Async.Uninitialized,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = { }, eventSink = { },
) )

View file

@ -65,6 +65,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -172,6 +173,8 @@ fun ConfigureRoomView(
else -> Unit else -> Unit
} }
PermissionsView(state = state.cameraPermissionState)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

View file

@ -37,6 +37,8 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every import io.mockk.every
@ -70,6 +72,7 @@ class ConfigureRoomPresenterTests {
private lateinit var fakePickerProvider: FakePickerProvider private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private lateinit var fakeAnalyticsService: FakeAnalyticsService private lateinit var fakeAnalyticsService: FakeAnalyticsService
private lateinit var fakePermissionsPresenter: FakePermissionsPresenter
@Before @Before
fun setup() { fun setup() {
@ -79,12 +82,18 @@ class ConfigureRoomPresenterTests {
fakePickerProvider = FakePickerProvider() fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor() fakeMediaPreProcessor = FakeMediaPreProcessor()
fakeAnalyticsService = FakeAnalyticsService() fakeAnalyticsService = FakeAnalyticsService()
fakePermissionsPresenter = FakePermissionsPresenter()
presenter = ConfigureRoomPresenter( presenter = ConfigureRoomPresenter(
dataStore = createRoomDataStore, dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient, matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return fakePermissionsPresenter
}
},
) )
mockkStatic(File::readBytes) mockkStatic(File::readBytes)
@ -170,8 +179,6 @@ class ConfigureRoomPresenterTests {
// Room avatar // Room avatar
// Pick avatar // Pick avatar
fakePickerProvider.givenResult(null) fakePickerProvider.givenResult(null)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
// From gallery // From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY) val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
fakePickerProvider.givenResult(uriFromGallery) fakePickerProvider.givenResult(uriFromGallery)
@ -182,6 +189,10 @@ class ConfigureRoomPresenterTests {
// From camera // From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA) val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
fakePickerProvider.givenResult(uriFromCamera) fakePickerProvider.givenResult(uriFromCamera)
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
fakePermissionsPresenter.setPermissionGranted()
newState = awaitItem()
assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera) expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)

View file

@ -28,6 +28,6 @@ open class NotificationsOptInStateProvider : PreviewParameterProvider<Notificati
} }
fun aNotificationsOptInState() = NotificationsOptInState( fun aNotificationsOptInState() = NotificationsOptInState(
notificationsPermissionState = aPermissionsState(), notificationsPermissionState = aPermissionsState(showDialog = false),
eventSink = {} eventSink = {}
) )

View file

@ -47,6 +47,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
@ -71,6 +72,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)

View file

@ -39,6 +39,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,8 +51,11 @@ class EditUserProfilePresenter @AssistedInject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<EditUserProfileState> { ) : Presenter<EditUserProfileState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter fun create(matrixUser: MatrixUser): EditUserProfilePresenter
@ -58,6 +63,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): EditUserProfileState { override fun present(): EditUserProfileState {
val cameraPermissionState = cameraPermissionPresenter.present()
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) } var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) } var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
@ -85,7 +91,11 @@ class EditUserProfilePresenter @AssistedInject constructor(
is EditUserProfileEvents.HandleAvatarAction -> { is EditUserProfileEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
cameraPermissionState.eventSink.invoke(PermissionsEvents.OpenSystemDialog)
}
AvatarAction.Remove -> userAvatarUri = null AvatarAction.Remove -> userAvatarUri = null
} }
} }
@ -108,6 +118,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
avatarActions = avatarActions, avatarActions = avatarActions,
saveButtonEnabled = canSave && saveAction.value !is Async.Loading, saveButtonEnabled = canSave && saveAction.value !is Async.Loading,
saveAction = saveAction.value, saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },
) )
} }

View file

@ -20,6 +20,7 @@ import android.net.Uri
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class EditUserProfileState( data class EditUserProfileState(
@ -29,5 +30,6 @@ data class EditUserProfileState(
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean, val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>, val saveAction: Async<Unit>,
val cameraPermissionState: PermissionsState,
val eventSink: (EditUserProfileEvents) -> Unit val eventSink: (EditUserProfileEvents) -> Unit
) )

View file

@ -19,6 +19,7 @@ package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> { open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
@ -36,5 +37,6 @@ fun aEditUserProfileState() = EditUserProfileState(
avatarActions = persistentListOf(), avatarActions = persistentListOf(),
saveAction = Async.Uninitialized, saveAction = Async.Uninitialized,
saveButtonEnabled = true, saveButtonEnabled = true,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = {} eventSink = {}
) )

View file

@ -58,6 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -168,6 +169,7 @@ fun EditUserProfileView(
else -> Unit else -> Unit
} }
} }
PermissionsView(state = state.cameraPermissionState)
} }
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =

View file

@ -32,6 +32,8 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.mockk.every import io.mockk.every
@ -78,12 +80,18 @@ class EditUserProfilePresenterTest {
private fun createEditUserProfilePresenter( private fun createEditUserProfilePresenter(
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
matrixUser: MatrixUser = aMatrixUser(), matrixUser: MatrixUser = aMatrixUser(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): EditUserProfilePresenter { ): EditUserProfilePresenter {
return EditUserProfilePresenter( return EditUserProfilePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = matrixUser, matrixUser = matrixUser,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return permissionsPresenter
}
},
) )
} }
@ -157,13 +165,21 @@ class EditUserProfilePresenterTest {
fun `present - obtains avatar uris from camera`() = runTest { fun `present - obtains avatar uris from camera`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user) val fakePermissionsPresenter = FakePermissionsPresenter()
val presenter = createEditUserProfilePresenter(
matrixUser = user,
permissionsPresenter = fakePermissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
fakePermissionsPresenter.setPermissionGranted()
val stateWithPermission = awaitItem()
assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue()
stateWithPermission.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
awaitItem().apply { awaitItem().apply {
assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri) assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri)
} }

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.permissions.api)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api) api(projects.services.apperror.api)
@ -59,6 +60,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -39,6 +39,8 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,10 +51,14 @@ class RoomDetailsEditPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<RoomDetailsEditState> { ) : Presenter<RoomDetailsEditState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@Composable @Composable
override fun present(): RoomDetailsEditState { override fun present(): RoomDetailsEditState {
val cameraPermissionState = cameraPermissionPresenter.present()
val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState()
// Since there is no way to obtain the new avatar uri after uploading a new avatar, // Since there is no way to obtain the new avatar uri after uploading a new avatar,
@ -110,7 +116,11 @@ class RoomDetailsEditPresenter @Inject constructor(
is RoomDetailsEditEvents.HandleAvatarAction -> { is RoomDetailsEditEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
cameraPermissionState.eventSink(PermissionsEvents.OpenSystemDialog)
}
AvatarAction.Remove -> roomAvatarUri = null AvatarAction.Remove -> roomAvatarUri = null
} }
} }
@ -132,6 +142,7 @@ class RoomDetailsEditPresenter @Inject constructor(
avatarActions = avatarActions, avatarActions = avatarActions,
saveButtonEnabled = saveButtonEnabled, saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction.value, saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -17,8 +17,9 @@
package io.element.android.features.roomdetails.impl.edit package io.element.android.features.roomdetails.impl.edit
import android.net.Uri import android.net.Uri
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class RoomDetailsEditState( data class RoomDetailsEditState(
@ -32,5 +33,6 @@ data class RoomDetailsEditState(
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean, val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>, val saveAction: Async<Unit>,
val cameraPermissionState: PermissionsState,
val eventSink: (RoomDetailsEditEvents) -> Unit val eventSink: (RoomDetailsEditEvents) -> Unit
) )

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl.edit
import android.net.Uri import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> { open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> {
@ -45,5 +46,6 @@ fun aRoomDetailsEditState() = RoomDetailsEditState(
avatarActions = persistentListOf(), avatarActions = persistentListOf(),
saveButtonEnabled = true, saveButtonEnabled = true,
saveAction = Async.Uninitialized, saveAction = Async.Uninitialized,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = {} eventSink = {}
) )

View file

@ -62,6 +62,7 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -193,6 +194,8 @@ fun RoomDetailsEditView(
else -> Unit else -> Unit
} }
PermissionsView(state = state.cameraPermissionState)
} }
@Composable @Composable

View file

@ -32,6 +32,8 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -74,11 +76,19 @@ class RoomDetailsEditPresenterTest {
unmockkAll() unmockkAll()
} }
private fun aRoomDetailsEditPresenter(room: MatrixRoom): RoomDetailsEditPresenter { private fun aRoomDetailsEditPresenter(
room: MatrixRoom,
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): RoomDetailsEditPresenter {
return RoomDetailsEditPresenter( return RoomDetailsEditPresenter(
room = room, room = room,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return permissionsPresenter
}
},
) )
} }
@ -252,16 +262,22 @@ class RoomDetailsEditPresenterTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter()
val presenter = aRoomDetailsEditPresenter(room) val presenter = aRoomDetailsEditPresenter(
room = room,
permissionsPresenter = fakePermissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) fakePermissionsPresenter.setPermissionGranted()
val stateWithPermission = awaitItem()
assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue()
stateWithPermission.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
awaitItem().apply { awaitItem().apply {
assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri) assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri)
} }

View file

@ -22,17 +22,19 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class PermissionsViewStateProvider : PreviewParameterProvider<PermissionsState> { open class PermissionsViewStateProvider : PreviewParameterProvider<PermissionsState> {
override val values: Sequence<PermissionsState> override val values: Sequence<PermissionsState>
get() = sequenceOf( get() = sequenceOf(
aPermissionsState(), aPermissionsState(showDialog = true),
aPermissionsState().copy(shouldShowRationale = true), aPermissionsState(showDialog = true).copy(shouldShowRationale = true),
aPermissionsState().copy(permissionAlreadyDenied = true), aPermissionsState(showDialog = true).copy(permissionAlreadyDenied = true),
) )
} }
fun aPermissionsState() = PermissionsState( fun aPermissionsState(
showDialog: Boolean,
) = PermissionsState(
permission = Manifest.permission.INTERNET, permission = Manifest.permission.INTERNET,
permissionGranted = false, permissionGranted = false,
shouldShowRationale = false, shouldShowRationale = false,
showDialog = true, showDialog = showDialog,
permissionAlreadyAsked = false, permissionAlreadyAsked = false,
permissionAlreadyDenied = false, permissionAlreadyDenied = false,
eventSink = {} eventSink = {}

View file

@ -24,7 +24,7 @@ import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
class FakePermissionsPresenter( class FakePermissionsPresenter(
private val initialState: PermissionsState = aPermissionsState().copy(showDialog = false), private val initialState: PermissionsState = aPermissionsState(showDialog = false),
) : PermissionsPresenter { ) : PermissionsPresenter {
private fun eventSink(events: PermissionsEvents) { private fun eventSink(events: PermissionsEvents) {