diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 342e05532c..4a81d52d46 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,16 @@ android:supportsRtl="true" android:theme="@style/Theme.ElementX" tools:targetApi="33"> + + + + + + + + + diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 9b43d41d4d..1eb3840eb3 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(projects.libraries.textcomposer) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.mediapickers) + implementation(projects.libraries.featureflag.api) implementation(projects.features.networkmonitor.api) implementation(libs.coil.compose) implementation(libs.datetime) @@ -55,6 +57,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.libraries.featureflag.test) androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) diff --git a/features/messages/impl/src/androidTest/kotlin/io/element/android/features/messages/ExampleInstrumentedTest.kt b/features/messages/impl/src/androidTest/kotlin/io/element/android/features/messages/ExampleInstrumentedTest.kt deleted file mode 100644 index 97ef4f5a3b..0000000000 --- a/features/messages/impl/src/androidTest/kotlin/io/element/android/features/messages/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2022 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.features.messages - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.element.android.features.messages.test", appContext.packageName) - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt index 1da1188ee4..610f6e1ec0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt @@ -24,4 +24,6 @@ sealed interface MessageComposerEvents { object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents data class UpdateText(val text: CharSequence) : MessageComposerEvents + + object TakePhoto : MessageComposerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt index a5cc266e02..603776e518 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt @@ -21,23 +21,37 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.toStableCharSequence +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.mediapickers.PickerProvider import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject class MessageComposerPresenter @Inject constructor( private val appCoroutineScope: CoroutineScope, - private val room: MatrixRoom + private val room: MatrixRoom, + private val mediaPickerProvider: PickerProvider, + private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable override fun present(): MessageComposerState { + val localCoroutineScope = rememberCoroutineScope() + + // Example usage of custom pickers + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri -> + Timber.d("Photo saved at $uri") + }) + val isFullScreen = rememberSaveable { mutableStateOf(false) } @@ -63,9 +77,14 @@ class MessageComposerPresenter @Inject constructor( text.value = "".toStableCharSequence() composerMode.setToNormal() } + is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text) is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode - } + MessageComposerEvents.TakePhoto -> localCoroutineScope.launch { + if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) { + cameraPhotoPicker.launch() + } + }} } return MessageComposerState( @@ -92,6 +111,7 @@ class MessageComposerPresenter @Inject constructor( capturedMode.eventId, text ) + is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Reply -> room.replyMessage( capturedMode.eventId, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt index e94ebb0a2a..ebdfca6f9e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt @@ -53,6 +53,9 @@ fun MessageComposerView( composerMode = state.mode, onCloseSpecialMode = ::onCloseSpecialMode, onComposerTextChange = ::onComposerTextChange, + onAddAttachment = { + state.eventSink(MessageComposerEvents.TakePhoto) + }, composerCanSendMessage = state.isSendButtonVisible, composerText = state.text?.charSequence?.toString(), isInDarkMode = !ElementTheme.colors.isLight, 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 a7bc112174..83abbb2e0e 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 @@ -31,10 +31,12 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediapickers.PickerProvider import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -129,7 +131,9 @@ class MessagesPresenterTest { ): MessagesPresenter { val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, - room = matrixRoom + room = matrixRoom, + mediaPickerProvider = PickerProvider(isInTest = true), + featureFlagService = FakeFeatureFlagService(), ) val timelinePresenter = TimelinePresenter( 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 105d2ce31a..b18e9631ef 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 @@ -27,23 +27,37 @@ import io.element.android.features.messages.impl.textcomposer.MessageComposerEve import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.textcomposer.MessageComposerState import io.element.android.libraries.core.data.StableCharSequence +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_REPLY import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediapickers.PickerProvider import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Test class MessageComposerPresenterTest { + + private val pickerProvider = PickerProvider(isInTest = true) + private val featureFlagService = FakeFeatureFlagService().apply { + runBlocking { + setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true) + } + } + @Test fun `present - initial state`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -60,7 +74,9 @@ class MessageComposerPresenterTest { fun `present - toggle fullscreen`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -79,7 +95,9 @@ class MessageComposerPresenterTest { fun `present - change message`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -100,7 +118,9 @@ class MessageComposerPresenterTest { fun `present - change mode to edit`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -130,7 +150,9 @@ class MessageComposerPresenterTest { fun `present - change mode to reply`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -150,7 +172,9 @@ class MessageComposerPresenterTest { fun `present - change mode to quote`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -170,7 +194,9 @@ class MessageComposerPresenterTest { fun `present - send message`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -192,7 +218,9 @@ class MessageComposerPresenterTest { val fakeMatrixRoom = FakeMatrixRoom() val presenter = MessageComposerPresenter( this, - fakeMatrixRoom + fakeMatrixRoom, + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -223,7 +251,9 @@ class MessageComposerPresenterTest { val fakeMatrixRoom = FakeMatrixRoom() val presenter = MessageComposerPresenter( this, - fakeMatrixRoom + fakeMatrixRoom, + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -248,6 +278,25 @@ class MessageComposerPresenterTest { assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY) } } + + @Test + fun `present - Take photo`() = runTest { + val fakeMatrixRoom = FakeMatrixRoom() + val presenter = MessageComposerPresenter( + this, + fakeMatrixRoom, + pickerProvider, + featureFlagService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.TakePhoto) + + // TODO verify some post processing of the captured image is done + } + } } fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) diff --git a/libraries/mediapickers/build.gradle.kts b/libraries/mediapickers/build.gradle.kts new file mode 100644 index 0000000000..444244d2f0 --- /dev/null +++ b/libraries/mediapickers/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 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. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.mediapickers" + + dependencies { + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.core) + implementation(libs.inject) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + testImplementation(libs.test.robolectric) + } +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt new file mode 100644 index 0000000000..16f21a9683 --- /dev/null +++ b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt @@ -0,0 +1,51 @@ +/* + * 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.mediapickers + +import androidx.activity.compose.ManagedActivityResultLauncher + +/** + * Wrapper around [ManagedActivityResultLauncher] to be used with media/file pickers. + */ +interface PickerLauncher { + /** Starts the activity result launcher with its default input. */ + fun launch() + + /** Starts the activity result launcher with a [customInput]. */ + fun launch(customInput: Input) +} + +class ComposePickerLauncher( + private val managedLauncher: ManagedActivityResultLauncher, + private val defaultRequest: Input, +) : PickerLauncher { + override fun launch() { + managedLauncher.launch(defaultRequest) + } + + override fun launch(customInput: Input) { + managedLauncher.launch(customInput) + } +} + +/** Needed for screenshot tests. */ +class NoOpPickerLauncher( + private val onResult: () -> Unit, +) : PickerLauncher { + override fun launch() = onResult() + override fun launch(customInput: Input) = onResult() +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt new file mode 100644 index 0000000000..720378aaca --- /dev/null +++ b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt @@ -0,0 +1,150 @@ +/* + * 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.mediapickers + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.core.content.FileProvider +import io.element.android.libraries.core.mimetype.MimeTypes +import java.io.File +import java.util.UUID +import javax.inject.Inject + +class PickerProvider constructor(private val isInTest: Boolean) { + + @Inject + constructor(): this(false) + + /** + * Remembers and returns a [PickerLauncher] for a certain media/file [type]. + */ + @Composable + internal fun rememberPickerLauncher( + type: PickerType, + onResult: (Output) -> Unit, + ): PickerLauncher { + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { } + } else { + val contract = type.getContract() + val managedLauncher = rememberLauncherForActivityResult(contract = contract, onResult = onResult) + remember(type) { ComposePickerLauncher(managedLauncher, type.getDefaultRequest()) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video. + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + fun registerGalleryPicker(onResult: (Uri?) -> Unit): PickerLauncher { + // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> onResult(uri) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default). + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + fun registerFilePicker(mimeType: String = MimeTypes.Any, onResult: (Uri?) -> Unit): PickerLauncher { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> onResult(uri) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for taking a photo with a camera app. + * @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected. + * @param [deleteAfter] When it's `true`, the taken photo will be automatically removed after calling [onResult]. + * It's `true` by default. + */ + @Composable + fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + val context = LocalContext.current + val tmpFile = remember { getTemporaryFile(context) } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) } + rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success -> + // Execute callback + onResult(if (success) tmpFileUri else null) + // Then remove the file and clear the picker + if (deleteAfter) { + tmpFile.delete() + } + } + } + } + + /** + * Remembers and returns a [PickerLauncher] for recording a video with a camera app. + * @param [onResult] will be called with either the video's [Uri] or `null` if nothing was selected. + * @param [deleteAfter] When it's `true`, the recorded video will be automatically removed after calling [onResult]. + * It's `true` by default. + */ + @Composable + fun registerCameraVideoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + val context = LocalContext.current + val tmpFile = remember { getTemporaryFile(context) } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) } + rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success -> + // Execute callback + onResult(if (success) tmpFileUri else null) + // Then remove the file and clear the picker + if (deleteAfter) { + tmpFile.delete() + } + } + } + } + + private fun getTemporaryFile( + context: Context, + baseFolder: File = context.cacheDir, + filename: String = UUID.randomUUID().toString(), + ): File { + return File(baseFolder, filename) + } + + private fun getTemporaryUri( + context: Context, + file: File, + ): Uri { + val authority = "${context.packageName}.fileprovider" + return FileProvider.getUriForFile(context, authority, file) + } +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt new file mode 100644 index 0000000000..354d9a4918 --- /dev/null +++ b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt @@ -0,0 +1,58 @@ +/* + * 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.mediapickers + +import android.net.Uri +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import io.element.android.libraries.core.mimetype.MimeTypes + +sealed interface PickerType { + fun getContract(): ActivityResultContract + fun getDefaultRequest(): Input + + object ImageAndVideo : PickerType { + override fun getContract() = ActivityResultContracts.PickVisualMedia() + override fun getDefaultRequest(): PickVisualMediaRequest { + return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + } + } + + object Camera { + data class Photo(val destUri: Uri) : PickerType { + override fun getContract() = ActivityResultContracts.TakePicture() + override fun getDefaultRequest(): Uri { + return destUri + } + } + + data class Video(val destUri: Uri) : PickerType { + override fun getContract() = ActivityResultContracts.CaptureVideo() + override fun getDefaultRequest(): Uri { + return destUri + } + } + } + + data class File(val mimeType: String = MimeTypes.Any) : PickerType { + override fun getContract() = ActivityResultContracts.GetContent() + override fun getDefaultRequest(): String { + return mimeType + } + } +} diff --git a/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt b/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt new file mode 100644 index 0000000000..693ef40cfe --- /dev/null +++ b/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt @@ -0,0 +1,65 @@ +/* + * 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.mediapickers + +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PickerTypeTests { + + @Test + fun `ImageAndVideo - assert types`() { + val pickerType = PickerType.ImageAndVideo + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.PickVisualMedia::class.java) + assertThat(pickerType.getDefaultRequest().mediaType).isEqualTo(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + } + + @Test + fun `File - assert types`() { + val pickerType = PickerType.File() + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(MimeTypes.Any) + + val mimeType = MimeTypes.Images + val customPickerType = PickerType.File(mimeType) + assertThat(customPickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java) + assertThat(customPickerType.getDefaultRequest()).isEqualTo(mimeType) + } + + @Test + fun `CameraPhoto - assert types`() { + val uri = Uri.parse("file:///tmp/test") + val pickerType = PickerType.Camera.Photo(uri) + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.TakePicture::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(uri) + } + + @Test + fun `CameraVideo - assert types`() { + val uri = Uri.parse("file:///tmp/test") + val pickerType = PickerType.Camera.Video(uri) + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.CaptureVideo::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(uri) + } + +} diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 3481a56388..bab573de58 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -50,6 +50,7 @@ fun TextComposer( onFullscreenToggle: () -> Unit = {}, onCloseSpecialMode: () -> Unit = {}, onComposerTextChange: (CharSequence) -> Unit = {}, + onAddAttachment:() -> Unit = {}, ) { if (LocalInspectionMode.current) { FakeComposer(modifier) @@ -78,6 +79,7 @@ fun TextComposer( } override fun onAddAttachment() { + onAddAttachment() } override fun onExpandOrCompactChange() { diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 5d1ef9a20e..18ce6e55f8 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -90,7 +90,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:di")) implementation(project(":libraries:session-storage:impl")) implementation(project(":libraries:statemachine")) - + implementation(project(":libraries:mediapickers")) } fun DependencyHandlerScope.allServicesImpl() {