diff --git a/changelog.d/1395.bugfix b/changelog.d/1395.bugfix new file mode 100644 index 0000000000..2edd1fd369 --- /dev/null +++ b/changelog.d/1395.bugfix @@ -0,0 +1 @@ +Fix crash when trying to take a photo or record a video. diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 3ea54ca4ea..ae9818b727 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.libraries.deeplink) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.permissions.api) implementation(projects.libraries.usersearch.impl) implementation(projects.services.analytics.api) implementation(libs.coil.compose) @@ -64,6 +65,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.tests.testutils) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index 21d8f8d13f..4cefa82a31 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl.configureroom import android.net.Uri import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -40,6 +41,8 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider 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 kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -52,10 +55,15 @@ class ConfigureRoomPresenter @Inject constructor( private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, private val analyticsService: AnalyticsService, + permissionsPresenterFactory: PermissionsPresenter.Factory, ) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private var pendingPermissionRequest = false + @Composable override fun present(): ConfigureRoomState { + val cameraPermissionState = cameraPermissionPresenter.present() val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( @@ -75,6 +83,13 @@ class ConfigureRoomPresenter @Inject constructor( } } + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + cameraPhotoPicker.launch() + } + } + val localCoroutineScope = rememberCoroutineScope() val createRoomAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } @@ -93,7 +108,12 @@ class ConfigureRoomPresenter @Inject constructor( is ConfigureRoomEvents.HandleAvatarAction -> { when (event.action) { AvatarAction.ChoosePhoto -> galleryImagePicker.launch() - AvatarAction.TakePhoto -> cameraPhotoPicker.launch() + AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) } } @@ -106,6 +126,7 @@ class ConfigureRoomPresenter @Inject constructor( config = createRoomConfig.value, avatarActions = avatarActions, createRoomAction = createRoomAction.value, + cameraPermissionState = cameraPermissionState, eventSink = ::handleEvents, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt index 2e34f3bda2..7e16cedaa7 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt @@ -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.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.collections.immutable.ImmutableList data class ConfigureRoomState( val config: CreateRoomConfig, val avatarActions: ImmutableList, val createRoomAction: Async, + val cameraPermissionState: PermissionsState, val eventSink: (ConfigureRoomEvents) -> Unit ) { val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not() diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt index 0e31e9e1c0..eafbe6915b 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt @@ -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.userlist.aListOfSelectedUsers import io.element.android.libraries.architecture.Async +import io.element.android.libraries.permissions.api.aPermissionsState import kotlinx.collections.immutable.persistentListOf open class ConfigureRoomStateProvider : PreviewParameterProvider { @@ -41,5 +42,6 @@ fun aConfigureRoomState() = ConfigureRoomState( config = CreateRoomConfig(), avatarActions = persistentListOf(), createRoomAction = Async.Uninitialized, + cameraPermissionState = aPermissionsState(showDialog = false), eventSink = { }, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index 9bdef0b881..6398536e14 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -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.SelectedUsersList 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.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -73,9 +74,9 @@ import kotlinx.coroutines.launch @Composable fun ConfigureRoomView( state: ConfigureRoomState, + onBackPressed: () -> Unit, + onRoomCreated: (RoomId) -> Unit, modifier: Modifier = Modifier, - onBackPressed: () -> Unit = {}, - onRoomCreated: (RoomId) -> Unit = {}, ) { val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current @@ -172,6 +173,10 @@ fun ConfigureRoomView( else -> Unit } + + PermissionsView( + state = state.cameraPermissionState, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -278,5 +283,7 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview { ConfigureRoomView( state = state, + onBackPressed = {}, + onRoomCreated = {}, ) } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt index a330384f60..96935ed598 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt @@ -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.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.mockk.every @@ -55,6 +57,7 @@ import org.robolectric.RobolectricTestRunner import java.io.File private const val AN_URI_FROM_CAMERA = "content://uri_from_camera" +private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2" private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery" @RunWith(RobolectricTestRunner::class) @@ -70,6 +73,7 @@ class ConfigureRoomPresenterTests { private lateinit var fakePickerProvider: FakePickerProvider private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor private lateinit var fakeAnalyticsService: FakeAnalyticsService + private lateinit var fakePermissionsPresenter: FakePermissionsPresenter @Before fun setup() { @@ -79,12 +83,14 @@ class ConfigureRoomPresenterTests { fakePickerProvider = FakePickerProvider() fakeMediaPreProcessor = FakeMediaPreProcessor() fakeAnalyticsService = FakeAnalyticsService() + fakePermissionsPresenter = FakePermissionsPresenter() presenter = ConfigureRoomPresenter( dataStore = createRoomDataStore, matrixClient = fakeMatrixClient, mediaPickerProvider = fakePickerProvider, mediaPreProcessor = fakeMediaPreProcessor, analyticsService = fakeAnalyticsService, + permissionsPresenterFactory = FakePermissionsPresenterFactory(fakePermissionsPresenter), ) mockkStatic(File::readBytes) @@ -170,8 +176,6 @@ class ConfigureRoomPresenterTests { // Room avatar // Pick avatar fakePickerProvider.givenResult(null) - newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) - newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) // From gallery val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY) fakePickerProvider.givenResult(uriFromGallery) @@ -182,10 +186,23 @@ class ConfigureRoomPresenterTests { // From camera val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA) fakePickerProvider.givenResult(uriFromCamera) + assertThat(newState.cameraPermissionState.permissionGranted).isFalse() newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState = awaitItem() + assertThat(newState.cameraPermissionState.showDialog).isTrue() + fakePermissionsPresenter.setPermissionGranted() + newState = awaitItem() + assertThat(newState.cameraPermissionState.permissionGranted).isTrue() + newState = awaitItem() expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera) assertThat(newState.config).isEqualTo(expectedConfig) + // Do it again, no permission is requested + val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2) + fakePickerProvider.givenResult(uriFromCamera2) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2) + assertThat(newState.config).isEqualTo(expectedConfig) // Remove newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove)) newState = awaitItem() diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt index f3bffca590..43bdaa3732 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt @@ -65,7 +65,7 @@ class NotificationsOptInPresenter @AssistedInject constructor( if (notificationsPermissionsState.permissionGranted) { callback.onNotificationsOptInFinished() } else { - notificationsPermissionsState.eventSink(PermissionsEvents.OpenSystemDialog) + notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions) } } NotificationsOptInEvents.NotNowClicked -> { diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt index 230e125c1b..49596856ba 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt @@ -28,6 +28,6 @@ open class NotificationsOptInStateProvider : PreviewParameterProvider { + private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) + private var pendingEvent: MessageComposerEvents? = null + @SuppressLint("UnsafeOptInUsageError") @Composable override fun present(): MessageComposerState { val localCoroutineScope = rememberCoroutineScope() + val cameraPermissionState = cameraPermissionPresenter.present() val attachmentsState = remember { mutableStateOf(AttachmentsState.None) } @@ -132,6 +140,17 @@ class MessageComposerPresenter @Inject constructor( } } + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted) { + when (pendingEvent) { + is MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> cameraPhotoPicker.launch() + is MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> cameraVideoPicker.launch() + else -> Unit + } + pendingEvent = null + } + } + fun handleEvents(event: MessageComposerEvents) { when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value @@ -163,11 +182,21 @@ class MessageComposerPresenter @Inject constructor( } MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch { showAttachmentSourcePicker = false - cameraPhotoPicker.launch() + if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingEvent = event + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } } MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch { showAttachmentSourcePicker = false - cameraVideoPicker.launch() + if (cameraPermissionState.permissionGranted) { + cameraVideoPicker.launch() + } else { + pendingEvent = event + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } } MessageComposerEvents.PickAttachmentSource.Location -> { showAttachmentSourcePicker = false @@ -301,16 +330,16 @@ class MessageComposerPresenter @Inject constructor( } mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback).getOrThrow() } - .onSuccess { - attachmentState.value = AttachmentsState.None - } - .onFailure { cause -> - attachmentState.value = AttachmentsState.None - if (cause is CancellationException) { - throw cause - } else { - val snackbarMessage = SnackbarMessage(sendAttachmentError(cause)) - snackbarDispatcher.post(snackbarMessage) + .onSuccess { + attachmentState.value = AttachmentsState.None + } + .onFailure { cause -> + attachmentState.value = AttachmentsState.None + if (cause is CancellationException) { + throw cause + } else { + val snackbarMessage = SnackbarMessage(sendAttachmentError(cause)) + snackbarDispatcher.post(snackbarMessage) + } } - } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index db2abcc1dd..07662e4bcc 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -70,6 +70,9 @@ import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaSender 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.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule @@ -601,6 +604,7 @@ class MessagesPresenterTest { navigator: FakeMessagesNavigator = FakeMessagesNavigator(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), ): MessagesPresenter { val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, @@ -613,8 +617,8 @@ class MessagesPresenterTest { analyticsService = analyticsService, messageComposerContext = MessageComposerContextImpl(), richTextEditorStateFactory = TestRichTextEditorStateFactory(), - - ) + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + ) val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), room = matrixRoom, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index 3f66269fe1..b87fe16aad 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -54,6 +54,9 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaUploadInfo 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.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.textcomposer.Message import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.services.analytics.test.FakeAnalyticsService @@ -67,6 +70,7 @@ import org.junit.Rule import org.junit.Test import java.io.File +@Suppress("LargeClass") class MessageComposerPresenterTest { @get:Rule @@ -487,18 +491,84 @@ class MessageComposerPresenterTest { } @Test - fun `present - Take photo`() = runTest { + fun `present - create poll`() = runTest { val room = FakeMatrixRoom() val presenter = createPresenter(this, room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.AddAttachment) + val attachmentOpenState = awaitItem() + assertThat(attachmentOpenState.showAttachmentSourcePicker).isTrue() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.Poll) + val finalState = awaitItem() + assertThat(finalState.showAttachmentSourcePicker).isFalse() + } + } + + @Test + fun `present - share location`() = runTest { + val room = FakeMatrixRoom() + val presenter = createPresenter(this, room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.AddAttachment) + val attachmentOpenState = awaitItem() + assertThat(attachmentOpenState.showAttachmentSourcePicker).isTrue() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.Location) + val finalState = awaitItem() + assertThat(finalState.showAttachmentSourcePicker).isFalse() + } + } + + @Test + fun `present - Take photo`() = runTest { + val room = FakeMatrixRoom() + val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() } + val presenter = createPresenter( + this, + room = room, + permissionPresenter = permissionPresenter, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) - val previewingState = awaitItem() - assertThat(previewingState.showAttachmentSourcePicker).isFalse() - assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + val finalState = awaitItem() + assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - Take photo with permission request`() = runTest { + val room = FakeMatrixRoom() + val permissionPresenter = FakePermissionsPresenter() + val presenter = createPresenter( + this, + room = room, + permissionPresenter = permissionPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) + val permissionState = awaitItem() + assertThat(permissionState.showAttachmentSourcePicker).isFalse() + assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java) + permissionPresenter.setPermissionGranted() + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) cancelAndIgnoreRemainingEvents() } } @@ -506,16 +576,47 @@ class MessageComposerPresenterTest { @Test fun `present - Record video`() = runTest { val room = FakeMatrixRoom() - val presenter = createPresenter(this, room = room) + val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() } + val presenter = createPresenter( + this, + room = room, + permissionPresenter = permissionPresenter, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) - val previewingState = awaitItem() - assertThat(previewingState.showAttachmentSourcePicker).isFalse() - assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + val finalState = awaitItem() + assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - Record video with permission request`() = runTest { + val room = FakeMatrixRoom() + val permissionPresenter = FakePermissionsPresenter() + val presenter = createPresenter( + this, + room = room, + permissionPresenter = permissionPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) + val permissionState = awaitItem() + assertThat(permissionState.showAttachmentSourcePicker).isFalse() + assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java) + permissionPresenter.setPermissionGranted() + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -612,6 +713,7 @@ class MessageComposerPresenterTest { featureFlagService: FeatureFlagService = this.featureFlagService, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, + permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), ) = MessageComposerPresenter( coroutineScope, room, @@ -623,6 +725,7 @@ class MessageComposerPresenterTest { analyticsService, MessageComposerContextImpl(), TestRichTextEditorStateFactory(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), ) } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index b28a068708..f3b41859d9 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.permissions.api) implementation(projects.features.rageshake.api) implementation(projects.features.analytics.api) implementation(projects.features.ftue.api) @@ -71,6 +72,7 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.pushstore.test) testImplementation(projects.features.rageshake.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index 793c4840d7..be0a5f4cca 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -18,6 +18,7 @@ package io.element.android.features.preferences.impl.user.editprofile import android.net.Uri import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -39,6 +40,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider 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.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -49,8 +52,12 @@ class EditUserProfilePresenter @AssistedInject constructor( private val matrixClient: MatrixClient, private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, + permissionsPresenterFactory: PermissionsPresenter.Factory, ) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private var pendingPermissionRequest = false + @AssistedFactory interface Factory { fun create(matrixUser: MatrixUser): EditUserProfilePresenter @@ -58,6 +65,7 @@ class EditUserProfilePresenter @AssistedInject constructor( @Composable override fun present(): EditUserProfileState { + val cameraPermissionState = cameraPermissionPresenter.present() var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) } var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( @@ -77,6 +85,13 @@ class EditUserProfilePresenter @AssistedInject constructor( } } + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + cameraPhotoPicker.launch() + } + } + val saveAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() fun handleEvents(event: EditUserProfileEvents) { @@ -85,7 +100,12 @@ class EditUserProfilePresenter @AssistedInject constructor( is EditUserProfileEvents.HandleAvatarAction -> { when (event.action) { AvatarAction.ChoosePhoto -> galleryImagePicker.launch() - AvatarAction.TakePhoto -> cameraPhotoPicker.launch() + AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } AvatarAction.Remove -> userAvatarUri = null } } @@ -108,6 +128,7 @@ class EditUserProfilePresenter @AssistedInject constructor( avatarActions = avatarActions, saveButtonEnabled = canSave && saveAction.value !is Async.Loading, saveAction = saveAction.value, + cameraPermissionState = cameraPermissionState, eventSink = { handleEvents(it) }, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt index 87668e6f45..9561c8609b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt @@ -20,6 +20,7 @@ import android.net.Uri import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.collections.immutable.ImmutableList data class EditUserProfileState( @@ -29,5 +30,6 @@ data class EditUserProfileState( val avatarActions: ImmutableList, val saveButtonEnabled: Boolean, val saveAction: Async, + val cameraPermissionState: PermissionsState, val eventSink: (EditUserProfileEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt index 5e4ccb95cb..c4a8431f4f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt @@ -19,6 +19,7 @@ package io.element.android.features.preferences.impl.user.editprofile import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.permissions.api.aPermissionsState import kotlinx.collections.immutable.persistentListOf open class EditUserProfileStateProvider : PreviewParameterProvider { @@ -36,5 +37,6 @@ fun aEditUserProfileState() = EditUserProfileState( avatarActions = persistentListOf(), saveAction = Async.Uninitialized, saveButtonEnabled = true, + cameraPermissionState = aPermissionsState(showDialog = false), eventSink = {} ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 3aa7c798b4..760e08261d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -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.matrix.ui.components.AvatarActionBottomSheet 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.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -168,6 +169,9 @@ fun EditUserProfileView( else -> Unit } } + PermissionsView( + state = state.cameraPermissionState, + ) } private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt index beece60c9a..8746670e03 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -32,6 +32,9 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo 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.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.mockk.every @@ -78,12 +81,14 @@ class EditUserProfilePresenterTest { private fun createEditUserProfilePresenter( matrixClient: MatrixClient = FakeMatrixClient(), matrixUser: MatrixUser = aMatrixUser(), + permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), ): EditUserProfilePresenter { return EditUserProfilePresenter( matrixClient = matrixClient, matrixUser = matrixUser, mediaPickerProvider = fakePickerProvider, mediaPreProcessor = fakeMediaPreProcessor, + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), ) } @@ -157,16 +162,30 @@ class EditUserProfilePresenterTest { fun `present - obtains avatar uris from camera`() = runTest { val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) fakePickerProvider.givenResult(anotherAvatarUri) - val presenter = createEditUserProfilePresenter(matrixUser = user) + val fakePermissionsPresenter = FakePermissionsPresenter() + val presenter = createEditUserProfilePresenter( + matrixUser = user, + permissionsPresenter = fakePermissionsPresenter, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) + assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) - awaitItem().apply { - assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri) - } + val stateWithAskingPermission = awaitItem() + assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue() + fakePermissionsPresenter.setPermissionGranted() + val stateWithPermission = awaitItem() + assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue() + val stateWithNewAvatar = awaitItem() + assertThat(stateWithNewAvatar.userAvatarUrl).isEqualTo(anotherAvatarUri) + // Do it again, no permission is requested + fakePickerProvider.givenResult(userAvatarUri) + stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + val stateWithNewAvatar2 = awaitItem() + assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri) } } diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 2137b1401d..44c711a371 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.permissions.api) api(projects.features.roomdetails.api) api(projects.libraries.usersearch.api) api(projects.services.apperror.api) @@ -59,6 +60,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.tests.testutils) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt index 0024c64268..40df3791d4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -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.mediapickers.api.PickerProvider 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.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -49,10 +51,15 @@ class RoomDetailsEditPresenter @Inject constructor( private val room: MatrixRoom, private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, + permissionsPresenterFactory: PermissionsPresenter.Factory, ) : Presenter { + private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private var pendingPermissionRequest = false + @Composable override fun present(): RoomDetailsEditState { + val cameraPermissionState = cameraPermissionPresenter.present() val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() // Since there is no way to obtain the new avatar uri after uploading a new avatar, @@ -92,6 +99,13 @@ class RoomDetailsEditPresenter @Inject constructor( onResult = { uri -> if (uri != null) roomAvatarUri = uri } ) + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + cameraPhotoPicker.launch() + } + } + val avatarActions by remember(roomAvatarUri) { derivedStateOf { listOfNotNull( @@ -110,7 +124,12 @@ class RoomDetailsEditPresenter @Inject constructor( is RoomDetailsEditEvents.HandleAvatarAction -> { when (event.action) { AvatarAction.ChoosePhoto -> galleryImagePicker.launch() - AvatarAction.TakePhoto -> cameraPhotoPicker.launch() + AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } AvatarAction.Remove -> roomAvatarUri = null } } @@ -132,6 +151,7 @@ class RoomDetailsEditPresenter @Inject constructor( avatarActions = avatarActions, saveButtonEnabled = saveButtonEnabled, saveAction = saveAction.value, + cameraPermissionState = cameraPermissionState, eventSink = ::handleEvents, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt index ceb87b6f27..9258d882fc 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt @@ -17,8 +17,9 @@ package io.element.android.features.roomdetails.impl.edit 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.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.collections.immutable.ImmutableList data class RoomDetailsEditState( @@ -32,5 +33,6 @@ data class RoomDetailsEditState( val avatarActions: ImmutableList, val saveButtonEnabled: Boolean, val saveAction: Async, + val cameraPermissionState: PermissionsState, val eventSink: (RoomDetailsEditEvents) -> Unit ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt index 96fd47c381..730acaa1c5 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt @@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl.edit import android.net.Uri import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.Async +import io.element.android.libraries.permissions.api.aPermissionsState import kotlinx.collections.immutable.persistentListOf open class RoomDetailsEditStateProvider : PreviewParameterProvider { @@ -45,5 +46,6 @@ fun aRoomDetailsEditState() = RoomDetailsEditState( avatarActions = persistentListOf(), saveButtonEnabled = true, saveAction = Async.Uninitialized, + cameraPermissionState = aPermissionsState(showDialog = false), eventSink = {} ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt index 0c93c65b1e..3d0de05d34 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -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.matrix.ui.components.AvatarActionBottomSheet 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.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -193,6 +194,10 @@ fun RoomDetailsEditView( else -> Unit } + + PermissionsView( + state = state.cameraPermissionState, + ) } @Composable diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt index aeaefeab6e..03a47026dd 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt @@ -32,6 +32,9 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo 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.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.tests.testutils.WarmUpRule import io.mockk.every import io.mockk.mockk @@ -74,11 +77,15 @@ class RoomDetailsEditPresenterTest { unmockkAll() } - private fun aRoomDetailsEditPresenter(room: MatrixRoom): RoomDetailsEditPresenter { + private fun aRoomDetailsEditPresenter( + room: MatrixRoom, + permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), + ): RoomDetailsEditPresenter { return RoomDetailsEditPresenter( room = room, mediaPickerProvider = fakePickerProvider, mediaPreProcessor = fakeMediaPreProcessor, + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), ) } @@ -252,19 +259,31 @@ class RoomDetailsEditPresenterTest { val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) fakePickerProvider.givenResult(anotherAvatarUri) - - val presenter = aRoomDetailsEditPresenter(room) + val fakePermissionsPresenter = FakePermissionsPresenter() + val presenter = aRoomDetailsEditPresenter( + room = room, + permissionsPresenter = fakePermissionsPresenter, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) - + assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) - awaitItem().apply { - assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri) - } + val stateWithAskingPermission = awaitItem() + assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue() + fakePermissionsPresenter.setPermissionGranted() + val stateWithPermission = awaitItem() + assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue() + val stateWithNewAvatar = awaitItem() + assertThat(stateWithNewAvatar.roomAvatarUrl).isEqualTo(anotherAvatarUri) + // Do it again, no permission is requested + fakePickerProvider.givenResult(roomAvatarUri) + stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + val stateWithNewAvatar2 = awaitItem() + assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri) } } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt index 45232a51db..0f3432b9d9 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.permissions.api sealed interface PermissionsEvents { - data object OpenSystemDialog : PermissionsEvents + data object RequestPermissions : PermissionsEvents data object CloseDialog : PermissionsEvents + data object OpenSystemSettingAndCloseDialog : PermissionsEvents } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt index 0765c464c9..ef15ab561d 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt @@ -16,67 +16,42 @@ package io.element.android.libraries.permissions.api +import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun PermissionsView( state: PermissionsState, modifier: Modifier = Modifier, - openSystemSettings: () -> Unit = {}, ) { if (state.showDialog.not()) return - when { - state.permissionGranted -> { - // Notification Granted, nothing to do - } - state.permissionAlreadyDenied -> { - // In this case, tell the user to go to the settings - ConfirmationDialog( - modifier = modifier, - title = "System", - content = "In order to let the application display notification, please grant the permission to the system settings", - submitText = "Open settings", - onSubmitClicked = { - state.eventSink.invoke(PermissionsEvents.CloseDialog) - openSystemSettings() - }, - onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, - ) - } - else -> { - val textToShow = if (state.shouldShowRationale) { - // TODO Move to state - // If the user has denied the permission but the rationale can be shown, - // then gently explain why the app requires this permission - // permissions_rationale_msg_notification - "To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages." - } else { - // TODO Move to state - // If it's the first time the user lands on this feature, or the user - // doesn't want to be asked again for this permission, explain that the - // permission is required - "To be able to receive notifications, please grant the permission." - } - ConfirmationDialog( - modifier = modifier, - title = "Notifications", - content = textToShow, - submitText = "Request permission", - onSubmitClicked = { - state.eventSink.invoke(PermissionsEvents.OpenSystemDialog) - }, - onCancelClicked = { - state.eventSink.invoke(PermissionsEvents.CloseDialog) - }, - onDismiss = {} - ) - } + ConfirmationDialog( + modifier = modifier, + title = stringResource(id = CommonStrings.common_permission), + content = state.permission.toDialogContent(), + submitText = stringResource(id = CommonStrings.action_open_settings), + onSubmitClicked = { + state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) + }, + onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, + ) +} + +@Composable +private fun String.toDialogContent(): String { + return when (this) { + Manifest.permission.POST_NOTIFICATIONS -> stringResource(id = R.string.dialog_permission_notification) + Manifest.permission.CAMERA -> stringResource(id = R.string.dialog_permission_camera) + Manifest.permission.RECORD_AUDIO -> stringResource(id = R.string.dialog_permission_microphone) + else -> stringResource(id = R.string.dialog_permission_generic) } } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt index e93b74d934..2009ad0e85 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt @@ -22,17 +22,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider open class PermissionsViewStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aPermissionsState(), - aPermissionsState().copy(shouldShowRationale = true), - aPermissionsState().copy(permissionAlreadyDenied = true), + aPermissionsState(showDialog = true, permission = Manifest.permission.POST_NOTIFICATIONS), + aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA), + aPermissionsState(showDialog = true, permission = Manifest.permission.RECORD_AUDIO), + aPermissionsState(showDialog = true, permission = Manifest.permission.INTERNET), ) } -fun aPermissionsState() = PermissionsState( - permission = Manifest.permission.INTERNET, +fun aPermissionsState( + showDialog: Boolean, + permission: String = Manifest.permission.POST_NOTIFICATIONS +) = PermissionsState( + permission = permission, permissionGranted = false, shouldShowRationale = false, - showDialog = true, + showDialog = showDialog, permissionAlreadyAsked = false, permissionAlreadyDenied = false, eventSink = {} diff --git a/libraries/permissions/api/src/main/res/values/localazy.xml b/libraries/permissions/api/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..4397e7d343 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values/localazy.xml @@ -0,0 +1,7 @@ + + + "In order to let the application use the camera, please grant the permission to the system settings." + "Please grant the permission to the system settings." + "In order to let the application use the microphone, please grant the permission to the system settings." + "In order to let the application display notification, please grant the permission to the system settings." + diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index 1684833d70..8fccfcc09d 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.PermissionsStore +import io.element.android.libraries.permissions.impl.action.PermissionActions import kotlinx.coroutines.launch import timber.log.Timber @@ -48,6 +49,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( @Assisted val permission: String, private val permissionsStore: PermissionsStore, private val composablePermissionStateProvider: ComposablePermissionStateProvider, + private val permissionActions: PermissionActions, ) : PermissionsPresenter { @AssistedFactory @@ -98,20 +100,27 @@ class DefaultPermissionsPresenter @AssistedInject constructor( LaunchedEffect(this) { if (permissionState.status.isGranted) { - // User may have granted permission from the settings, to reset the store regarding this permission + // User may have granted permission from the settings, so reset the store regarding this permission permissionsStore.resetPermission(permission) } } - val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) } + val showDialog = rememberSaveable { mutableStateOf(false) } fun handleEvents(event: PermissionsEvents) { when (event) { PermissionsEvents.CloseDialog -> { showDialog.value = false } - PermissionsEvents.OpenSystemDialog -> { - permissionState.launchPermissionRequest() + PermissionsEvents.RequestPermissions -> { + if (permissionState.status !is PermissionStatus.Granted && isAlreadyDenied) { + showDialog.value = true + } else { + permissionState.launchPermissionRequest() + } + } + PermissionsEvents.OpenSystemSettingAndCloseDialog -> { + permissionActions.openSettings() showDialog.value = false } } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt new file mode 100644 index 0000000000..a694c079e3 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.impl.action + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.system.openAppSettingsPage +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidPermissionActions @Inject constructor( + @ApplicationContext private val context: Context +) : PermissionActions { + + override fun openSettings() { + context.openAppSettingsPage() + } +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt new file mode 100644 index 0000000000..66c37bdb4d --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.impl.action + +interface PermissionActions { + fun openSettings() +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt index 316ce7bc67..ca96833d69 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt @@ -25,6 +25,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.common.truth.Truth.assertThat import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.impl.action.FakePermissionActions import io.element.android.libraries.permissions.test.InMemoryPermissionsStore import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -52,7 +53,8 @@ class DefaultPermissionsPresenterTest { val presenter = DefaultPermissionsPresenter( A_PERMISSION, permissionsStore, - permissionStateProvider + permissionStateProvider, + FakePermissionActions(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -69,7 +71,10 @@ class DefaultPermissionsPresenterTest { @Test fun `present - user closes dialog`() = runTest { - val permissionsStore = InMemoryPermissionsStore() + val permissionsStore = InMemoryPermissionsStore( + permissionDenied = true, + permissionAsked = true + ) val permissionState = FakePermissionState( A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false) @@ -81,18 +86,58 @@ class DefaultPermissionsPresenterTest { val presenter = DefaultPermissionsPresenter( A_PERMISSION, permissionsStore, - permissionStateProvider + permissionStateProvider, + FakePermissionActions(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + skipItems(1) val initialState = awaitItem() - assertThat(initialState.showDialog).isTrue() - initialState.eventSink.invoke(PermissionsEvents.CloseDialog) + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + val withDialogState = awaitItem() + assertThat(withDialogState.showDialog).isTrue() + withDialogState.eventSink.invoke(PermissionsEvents.CloseDialog) assertThat(awaitItem().showDialog).isFalse() } } + @Test + fun `present - user open settings`() = runTest { + val permissionsStore = InMemoryPermissionsStore( + permissionDenied = true, + permissionAsked = true + ) + val permissionState = FakePermissionState( + A_PERMISSION, + PermissionStatus.Denied(shouldShowRationale = false) + ) + val permissionStateProvider = + FakeComposablePermissionStateProvider( + permissionState + ) + val permissionActions = FakePermissionActions() + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + permissionActions, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + val withDialogState = awaitItem() + assertThat(withDialogState.showDialog).isTrue() + assertThat(permissionActions.openSettingsCalled).isFalse() + withDialogState.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) + assertThat(awaitItem().showDialog).isFalse() + assertThat(permissionActions.openSettingsCalled).isTrue() + } + } + @Test fun `present - user does not grant permission`() = runTest { val permissionsStore = InMemoryPermissionsStore() @@ -107,16 +152,16 @@ class DefaultPermissionsPresenterTest { val presenter = DefaultPermissionsPresenter( A_PERMISSION, permissionsStore, - permissionStateProvider + permissionStateProvider, + FakePermissionActions(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.showDialog).isTrue() - initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(initialState.showDialog).isFalse() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) assertThat(permissionState.launchPermissionRequestCalled).isTrue() - assertThat(awaitItem().showDialog).isFalse() // User does not grant permission permissionStateProvider.userGiveAnswer(answer = false, firstTime = true) skipItems(1) @@ -142,16 +187,16 @@ class DefaultPermissionsPresenterTest { val presenter = DefaultPermissionsPresenter( A_PERMISSION, permissionsStore, - permissionStateProvider + permissionStateProvider, + FakePermissionActions(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.showDialog).isTrue() - initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(initialState.showDialog).isFalse() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) assertThat(permissionState.launchPermissionRequestCalled).isTrue() - assertThat(awaitItem().showDialog).isFalse() // User does not grant permission permissionStateProvider.userGiveAnswer(answer = false, firstTime = false) skipItems(2) @@ -181,17 +226,20 @@ class DefaultPermissionsPresenterTest { val presenter = DefaultPermissionsPresenter( A_PERMISSION, permissionsStore, - permissionStateProvider + permissionStateProvider, + FakePermissionActions(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.showDialog).isTrue() - assertThat(initialState.permissionGranted).isFalse() - assertThat(initialState.permissionAlreadyDenied).isTrue() - assertThat(initialState.permissionAlreadyAsked).isTrue() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + val withDialogState = awaitItem() + assertThat(withDialogState.showDialog).isTrue() + assertThat(withDialogState.permissionGranted).isFalse() + assertThat(withDialogState.permissionAlreadyDenied).isTrue() + assertThat(withDialogState.permissionAlreadyAsked).isTrue() } } @@ -209,16 +257,16 @@ class DefaultPermissionsPresenterTest { val presenter = DefaultPermissionsPresenter( A_PERMISSION, permissionsStore, - permissionStateProvider + permissionStateProvider, + FakePermissionActions(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.showDialog).isTrue() - initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(initialState.showDialog).isFalse() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) assertThat(permissionState.launchPermissionRequestCalled).isTrue() - assertThat(awaitItem().showDialog).isFalse() // User grants permission permissionStateProvider.userGiveAnswer(answer = true, firstTime = true) skipItems(1) diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt new file mode 100644 index 0000000000..fa17329900 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.impl.action + +class FakePermissionActions : PermissionActions { + var openSettingsCalled = false + private set + + override fun openSettings() { + openSettingsCalled = true + } +} diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt index f26f268860..871f562489 100644 --- a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt @@ -24,13 +24,14 @@ import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.aPermissionsState class FakePermissionsPresenter( - private val initialState: PermissionsState = aPermissionsState().copy(showDialog = false), + private val initialState: PermissionsState = aPermissionsState(showDialog = false), ) : PermissionsPresenter { private fun eventSink(events: PermissionsEvents) { when (events) { - PermissionsEvents.OpenSystemDialog -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true) + PermissionsEvents.RequestPermissions -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true) PermissionsEvents.CloseDialog -> state.value = state.value.copy(showDialog = false) + PermissionsEvents.OpenSystemSettingAndCloseDialog -> state.value = state.value.copy(showDialog = false) } } diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenterFactory.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenterFactory.kt new file mode 100644 index 0000000000..05fcb2beb4 --- /dev/null +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenterFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.test + +import io.element.android.libraries.permissions.api.PermissionsPresenter + +class FakePermissionsPresenterFactory( + private val permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), +) : PermissionsPresenter.Factory { + override fun create(permission: String): PermissionsPresenter { + return permissionPresenter + } +} diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index bf042037fc..51a311bdf5 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -44,6 +44,7 @@ "No" "Not now" "OK" + "Open settings" "Open with" "Quick reply" "Quote" @@ -105,6 +106,7 @@ "Password" "People" "Permalink" + "Permission" "Total votes: %1$s" "Results will show after the poll has ended" "Privacy policy" diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_0,NEXUS_5,1.0,en].png index 9cc7127ed4..d67192a20e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9787976d603dddf43993583b316cf05fde9473d09e17641632ee93f1869a4314 -size 28307 +oid sha256:c3f0f1afc824e1fb49b50149ceb00ea8b687195c6e9f70010012f1c0aaed6af1 +size 32689 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_1,NEXUS_5,1.0,en].png index 2cfb0b3e04..168f341236 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a10a6b3ec837277fe6665847c85c9dac481d121b053b924b0c3334824f4497e3 -size 38688 +oid sha256:92ef5cef1f64f76c60cc3961a9510710948644529d1e8354f9218aa38f4ab647 +size 32146 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_2,NEXUS_5,1.0,en].png index e633306e21..4860b74b0c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c492e1d390829e70e099ccd7c94f970c51f0d2289376cae273dded8e1e2adb5 -size 32183 +oid sha256:e8bcbfe6bf79cb2b5d73a75f12a7588f78ca21e1a74ff9a6d0c87e194733ac73 +size 32693 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..00d168a412 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-D-0_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81239e6188eb3761d880e4b724a9fb5e10a852d0ad6a697f8eae753eb7ae95ef +size 25386 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_0,NEXUS_5,1.0,en].png index 635b47b57f..f79a11010f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6bd4c43636ee860ee8f22548596327d8be88b3c14246a763be1fd5580606f6d3 -size 24193 +oid sha256:9b32df35c4848bcaf45a1bef4cd61a3863ea47500e3cef0cdbeea887f6e98703 +size 28315 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_1,NEXUS_5,1.0,en].png index f6db105f79..ebea284193 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:109f7dafffc356b4c79e6325d462e3e313183419956cbf7f492c5767d0d6cfa8 -size 33810 +oid sha256:ac99b55aabf6777ce28a30c25a7747c37c46045c8b8cbbbe74b2a25890e8d47b +size 27706 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_2,NEXUS_5,1.0,en].png index 5398306b1b..ee5ff2e9f5 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a4e69aca1d1697796ceb859efe993c30ad778e6633be7cf471edbee3ea7fbbe -size 27821 +oid sha256:e2f038e9c9f19b099997b94ac95cb81a36b105e0b193bba97b7e6c17e85efdcd +size 28358 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b187454ed4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.permissions.api_null_PermissionsView-N-0_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e3188c584ccfeeeee801cf95f52e16e1c2420c34e682292f4ead8253e49200d +size 21863 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index c51e0f1eed..0d410fc9c4 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -50,6 +50,12 @@ "rich_text_editor.*" ] }, + { + "name": ":libraries:permissions:api", + "includeRegex": [ + "dialog\\.permission_.*" + ] + }, { "name": ":libraries:androidutils", "includeRegex": [