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() {