diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 931c995d9a..8c9a247172 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -120,6 +120,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(
@@ -219,6 +222,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object RoomDirectorySearch : NavTarget
+
+ @Parcelize
+ data class IncomingShare(val intent: Intent) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -375,6 +381,20 @@ class LoggedInFlowNode @AssistedInject constructor(
})
.build()
}
+ is NavTarget.IncomingShare -> {
+ shareEntryPoint.nodeBuilder(this, buildContext)
+ .callback(object : ShareEntryPoint.Callback {
+ override fun onDone(roomIds: List) {
+ navigateUp()
+ if (roomIds.size == 1) {
+ val targetRoomId = roomIds.first()
+ backstack.push(NavTarget.Room(targetRoomId.toRoomIdOrAlias()))
+ }
+ }
+ })
+ .params(ShareEntryPoint.Params(intent = navTarget.intent))
+ .build()
+ }
}
}
@@ -414,6 +434,17 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
+ internal suspend fun attachIncomingShare(intent: Intent) {
+ waitForNavTargetAttached { navTarget ->
+ navTarget is NavTarget.RoomList
+ }
+ attachChild {
+ backstack.push(
+ NavTarget.IncomingShare(intent)
+ )
+ }
+ }
+
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index 4b886d9c99..d200acb84e 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -283,6 +283,19 @@ class RootFlowNode @AssistedInject constructor(
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
+ is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
+ }
+ }
+
+ private suspend fun onIncomingShare(intent: Intent) {
+ // Is there a session already?
+ val latestSessionId = authenticationService.getLatestSessionId()
+ if (latestSessionId == null) {
+ // No session, open login
+ switchToNotLoggedInFlow()
+ } else {
+ attachSession(latestSessionId)
+ .attachIncomingShare(intent)
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
index dafbf8c283..d41fbbfebb 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
@@ -30,6 +30,7 @@ sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
+ data class IncomingShare(val intent: Intent) : ResolvedIntent
}
class IntentResolver @Inject constructor(
@@ -56,6 +57,10 @@ class IntentResolver @Inject constructor(
?.takeIf { it !is PermalinkData.FallbackLink }
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
+ if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) {
+ return ResolvedIntent.IncomingShare(intent)
+ }
+
// Unknown intent
Timber.w("Unknown intent")
return null
diff --git a/features/share/api/build.gradle.kts b/features/share/api/build.gradle.kts
new file mode 100644
index 0000000000..14528434a6
--- /dev/null
+++ b/features/share/api/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-library")
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.share.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+}
diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt
new file mode 100644
index 0000000000..0861e00ca2
--- /dev/null
+++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 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.share.api
+
+import android.content.Intent
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.matrix.api.core.RoomId
+
+interface ShareEntryPoint : FeatureEntryPoint {
+ data class Params(val intent: Intent) : NodeInputs
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface Callback : Plugin {
+ fun onDone(roomIds: List)
+ }
+
+ interface NodeBuilder {
+ fun params(params: Params): NodeBuilder
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+}
diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts
new file mode 100644
index 0000000000..5da977b4c0
--- /dev/null
+++ b/features/share/impl/build.gradle.kts
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.anvil)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.share.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ anvil(projects.anvilcodegen)
+ implementation(projects.anvilannotations)
+
+ implementation(projects.appconfig)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.mediaupload.api)
+ implementation(projects.libraries.roomselect.api)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.testtags)
+ api(libs.statemachine)
+ api(projects.features.share.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt
new file mode 100644
index 0000000000..95859cc8b0
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 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.share.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.share.api.ShareEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+import javax.inject.Inject
+
+@ContributesBinding(SessionScope::class)
+class DefaultShareEntryPoint @Inject constructor() : ShareEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ShareEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : ShareEntryPoint.NodeBuilder {
+ override fun params(params: ShareEntryPoint.Params): ShareEntryPoint.NodeBuilder {
+ plugins += ShareNode.Inputs(intent = params.intent)
+ return this
+ }
+
+ override fun callback(callback: ShareEntryPoint.Callback): ShareEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt
new file mode 100644
index 0000000000..9fe24660ec
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024 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.share.impl
+
+sealed interface ShareEvents {
+ data object ClearError : ShareEvents
+}
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt
new file mode 100644
index 0000000000..91ec95cda2
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2024 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.share.impl
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.net.Uri
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.androidutils.compat.getParcelableArrayListExtraCompat
+import io.element.android.libraries.androidutils.compat.getParcelableExtraCompat
+import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeApplication
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeFile
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeText
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import javax.inject.Inject
+
+interface ShareIntentHandler {
+ suspend fun handleIncomingShareIntent(
+ intent: Intent,
+ onFile: suspend (List) -> Boolean,
+ onPlainText: suspend (String) -> Boolean,
+ ): Boolean
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultShareIntentHandler @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : ShareIntentHandler {
+ data class FileToShare(
+ val uri: Uri,
+ val mimeType: String,
+ )
+
+ /**
+ * This methods aims to handle incoming share intents.
+ *
+ * @return true if it can handle the intent data, false otherwise
+ */
+ override suspend fun handleIncomingShareIntent(
+ intent: Intent,
+ onFile: suspend (List) -> Boolean,
+ onPlainText: suspend (String) -> Boolean,
+ ): Boolean {
+ val type = intent.resolveType(context) ?: return false
+ return when {
+ type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText)
+ type.isMimeTypeImage() ||
+ type.isMimeTypeVideo() ||
+ type.isMimeTypeAudio() ||
+ type.isMimeTypeApplication() ||
+ type.isMimeTypeFile() ||
+ type.isMimeTypeText() ||
+ type.isMimeTypeAny() -> onFile(getIncomingFiles(intent, type))
+ else -> false
+ }
+ }
+
+ private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean {
+ val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
+ return if (content?.isNotEmpty() == true) {
+ onPlainText(content)
+ } else {
+ false
+ }
+ }
+
+ /**
+ * Use this function to retrieve files which are shared from another application or internally
+ * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions.
+ */
+ private fun getIncomingFiles(data: Intent, type: String): List {
+ val uriList = mutableListOf()
+ if (data.action == Intent.ACTION_SEND) {
+ data.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uriList.add(it) }
+ } else if (data.action == Intent.ACTION_SEND_MULTIPLE) {
+ val extraUriList: List? = data.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM)
+ extraUriList?.let { uriList.addAll(it) }
+ }
+ val resInfoList: List = context.packageManager.queryIntentActivitiesCompat(data, PackageManager.MATCH_DEFAULT_ONLY)
+ uriList.forEach {
+ for (resolveInfo in resInfoList) {
+ val packageName: String = resolveInfo.activityInfo.packageName
+ // Replace implicit intent by an explicit to fix crash on some devices like Xiaomi.
+ // see https://juejin.cn/post/7031736325422186510
+ try {
+ context.grantUriPermission(packageName, it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ } catch (e: Exception) {
+ continue
+ }
+ data.action = null
+ data.component = ComponentName(packageName, resolveInfo.activityInfo.name)
+ break
+ }
+ }
+ return uriList.map { uri ->
+ FileToShare(
+ uri = uri,
+ mimeType = type
+ )
+ }
+ }
+}
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt
new file mode 100644
index 0000000000..85041a8c97
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 20244 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.share.impl
+
+import android.content.Intent
+import android.os.Parcelable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.ParentNode
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.share.api.ShareEntryPoint
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
+import io.element.android.libraries.roomselect.api.RoomSelectMode
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class ShareNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: SharePresenter.Factory,
+ private val roomSelectEntryPoint: RoomSelectEntryPoint,
+) : ParentNode(
+ navModel = PermanentNavModel(
+ navTargets = setOf(NavTarget),
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ @Parcelize
+ object NavTarget : Parcelable
+
+ data class Inputs(val intent: Intent) : NodeInputs
+
+ private val inputs = inputs()
+ private val presenter = presenterFactory.create(inputs.intent)
+ private val callbacks = plugins.filterIsInstance()
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ val callback = object : RoomSelectEntryPoint.Callback {
+ override fun onRoomSelected(roomIds: List) {
+ presenter.onRoomSelected(roomIds)
+ }
+
+ override fun onCancel() {
+ navigateUp()
+ }
+ }
+
+ return roomSelectEntryPoint.nodeBuilder(this, buildContext)
+ .callback(callback)
+ .params(RoomSelectEntryPoint.Params(mode = RoomSelectMode.Share))
+ .build()
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Box(modifier = modifier) {
+ // Will render to room select screen
+ Children(
+ navModel = navModel,
+ )
+
+ val state = presenter.present()
+ ShareView(
+ state = state,
+ onShareSuccess = ::onShareSuccess,
+ )
+ }
+ }
+
+ private fun onShareSuccess(roomIds: List) {
+ callbacks.forEach { it.onDone(roomIds) }
+ }
+}
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt
new file mode 100644
index 0000000000..47513e282b
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2024 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.share.impl
+
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.libraries.mediaupload.api.MediaSender
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class SharePresenter @AssistedInject constructor(
+ @Assisted private val intent: Intent,
+ private val appCoroutineScope: CoroutineScope,
+ private val shareIntentHandler: ShareIntentHandler,
+ private val matrixClient: MatrixClient,
+ private val mediaPreProcessor: MediaPreProcessor,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(intent: Intent): SharePresenter
+ }
+
+ private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized)
+
+ fun onRoomSelected(roomIds: List) {
+ appCoroutineScope.share(intent, roomIds, shareActionState)
+ }
+
+ @Composable
+ override fun present(): ShareState {
+ fun handleEvents(event: ShareEvents) {
+ when (event) {
+ ShareEvents.ClearError -> shareActionState.value = AsyncAction.Uninitialized
+ }
+ }
+
+ return ShareState(
+ shareAction = shareActionState.value,
+ eventSink = { handleEvents(it) }
+ )
+ }
+
+ private fun CoroutineScope.share(
+ intent: Intent,
+ roomIds: List,
+ shareActionState: MutableState>>,
+ ) = launch {
+ suspend {
+ val result = shareIntentHandler.handleIncomingShareIntent(
+ intent,
+ onFile = { filesToShare ->
+ roomIds
+ .map { roomId ->
+ val room = matrixClient.getRoom(roomId) ?: return@map false
+ val mediaSender = MediaSender(preProcessor = mediaPreProcessor, room = room)
+ filesToShare
+ .map { fileToShare ->
+ mediaSender.sendMedia(
+ uri = fileToShare.uri,
+ mimeType = fileToShare.mimeType,
+ compressIfPossible = true,
+ ).isSuccess
+ }
+ .all { it }
+ }
+ .all { it }
+ },
+ onPlainText = { text ->
+ roomIds
+ .map { roomId ->
+ matrixClient.getRoom(roomId)?.sendMessage(
+ body = text,
+ htmlBody = null,
+ mentions = emptyList(),
+ )?.isSuccess.orFalse()
+ }
+ .all { it }
+ }
+ )
+ if (!result) {
+ throw Exception("Failed to handle incoming share intent")
+ }
+ roomIds
+ }.runCatchingUpdatingState(shareActionState)
+ }
+}
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt
new file mode 100644
index 0000000000..b7e3510033
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2024 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.share.impl
+
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+
+data class ShareState(
+ val shareAction: AsyncAction>,
+ val eventSink: (ShareEvents) -> Unit
+)
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt
new file mode 100644
index 0000000000..a8b766f238
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2024 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.share.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+
+open class ShareStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aShareState(),
+ aShareState(
+ shareAction = AsyncAction.Loading,
+ ),
+ aShareState(
+ shareAction = AsyncAction.Success(
+ listOf(RoomId("!room2:domain")),
+ )
+ ),
+ aShareState(
+ shareAction = AsyncAction.Failure(Throwable("error")),
+ ),
+ )
+}
+
+fun aShareState(
+ shareAction: AsyncAction> = AsyncAction.Uninitialized,
+ eventSink: (ShareEvents) -> Unit = {}
+) = ShareState(
+ shareAction = shareAction,
+ eventSink = eventSink
+)
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt
new file mode 100644
index 0000000000..1bc3e2325b
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 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.share.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.matrix.api.core.RoomId
+
+@Composable
+fun ShareView(
+ state: ShareState,
+ onShareSuccess: (List) -> Unit,
+) {
+ AsyncActionView(
+ async = state.shareAction,
+ onSuccess = {
+ onShareSuccess(it)
+ },
+ onErrorDismiss = {
+ state.eventSink(ShareEvents.ClearError)
+ },
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ShareViewPreview(@PreviewParameter(ShareStateProvider::class) state: ShareState) = ElementPreview {
+ ShareView(
+ state = state,
+ onShareSuccess = {}
+ )
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt
index 3c897b3d88..2bc5380156 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt
@@ -16,9 +16,32 @@
package io.element.android.libraries.androidutils.compat
+import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
import android.os.Build
+import android.os.Parcelable
+
+inline fun Intent.getParcelableExtraCompat(key: String): T? = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelableExtra(key, T::class.java)
+ else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T?
+}
+
+inline fun Intent.getParcelableArrayListExtraCompat(key: String): ArrayList? = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelableArrayListExtra(key, T::class.java)
+ else -> @Suppress("DEPRECATION") getParcelableArrayListExtra(key)
+}
+
+fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List {
+ return when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> queryIntentActivities(
+ data,
+ PackageManager.ResolveInfoFlags.of(flags.toLong())
+ )
+ else -> @Suppress("DEPRECATION") queryIntentActivities(data, flags)
+ }
+}
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo {
return when {
diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt
index d3f63e366c..bff273aa9f 100644
--- a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt
+++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt
@@ -18,4 +18,5 @@ package io.element.android.libraries.roomselect.api
enum class RoomSelectMode {
Forward,
+ Share,
}
diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt
index 7bfddeb280..2821efd36a 100644
--- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt
+++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt
@@ -31,29 +31,33 @@ open class RoomSelectStateProvider : PreviewParameterProvider {
get() = sequenceOf(
aRoomSelectState(),
aRoomSelectState(query = "Test", isSearchActive = true),
- aRoomSelectState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())),
+ aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState(
- resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
+ resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
isSearchActive = true,
),
aRoomSelectState(
- resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
+ resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
isSearchActive = true,
selectedRooms = persistentListOf(aRoomSummaryDetails(roomId = RoomId("!room2:domain")))
),
- // Add other states here
+ aRoomSelectState(
+ mode = RoomSelectMode.Share,
+ resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
+ ),
)
}
private fun aRoomSelectState(
+ mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState> = SearchBarResultState.Initial(),
query: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList = persistentListOf(),
) = RoomSelectState(
- mode = RoomSelectMode.Forward,
+ mode = mode,
resultState = resultState,
query = query,
isSearchActive = isSearchActive,
@@ -61,7 +65,7 @@ private fun aRoomSelectState(
eventSink = {}
)
-private fun aForwardMessagesRoomList() = persistentListOf(
+private fun aRoomSelectRoomList() = persistentListOf(
aRoomSummaryDetails(),
aRoomSummaryDetails(
roomId = RoomId("!room2:domain"),
diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt
index 438cb8d4e6..fa8525bdc1 100644
--- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt
+++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt
@@ -105,6 +105,7 @@ fun RoomSelectView(
Text(
text = when (state.mode) {
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message)
+ RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to)
},
style = ElementTheme.typography.aliasScreenTitle
)
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 8d46b6e5b1..a106be8b5a 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -197,6 +197,7 @@
"Search results"
"Security"
"Seen by"
+ "Send to"
"Sending…"
"Sending failed"
"Sent"