diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 931c995d9a..b3872a18fd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -122,6 +122,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(
@@ -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/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
index cfd686a655..fe76113a95 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
@@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
import im.vector.app.features.analytics.plan.SuperProperties
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
+import io.element.android.features.share.api.ShareService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.services.analytics.api.AnalyticsService
@@ -34,6 +35,7 @@ class RootPresenter @Inject constructor(
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
private val appErrorStateService: AppErrorStateService,
private val analyticsService: AnalyticsService,
+ private val shareService: ShareService,
private val sdkMetadata: SdkMetadata,
) : Presenter {
@Composable
@@ -52,6 +54,10 @@ class RootPresenter @Inject constructor(
)
}
+ LaunchedEffect(Unit) {
+ shareService.observeFeatureFlag(this)
+ }
+
return RootState(
rageshakeDetectionState = rageshakeDetectionState,
crashDetectionState = crashDetectionState,
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
index 118ecd1e46..aeaeb10859 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
@@ -28,6 +28,8 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
+import io.element.android.features.share.api.ShareService
+import io.element.android.features.share.test.FakeShareService
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -35,6 +37,8 @@ import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -55,6 +59,22 @@ class RootPresenterTest {
}
}
+ @Test
+ fun `present - check that share service is invoked`() = runTest {
+ val lambda = lambdaRecorder { _ -> }
+ val presenter = createRootPresenter(
+ shareService = FakeShareService {
+ lambda(it)
+ }
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(2)
+ lambda.assertions().isCalledOnce()
+ }
+ }
+
@Test
fun `present - passes app error state`() = runTest {
val presenter = createRootPresenter(
@@ -79,7 +99,8 @@ class RootPresenterTest {
}
private fun createRootPresenter(
- appErrorService: AppErrorStateService = DefaultAppErrorStateService()
+ appErrorService: AppErrorStateService = DefaultAppErrorStateService(),
+ shareService: ShareService = FakeShareService {},
): RootPresenter {
val crashDataStore = FakeCrashDataStore()
val rageshakeDataStore = FakeRageshakeDataStore()
@@ -102,6 +123,7 @@ class RootPresenterTest {
rageshakeDetectionPresenter = rageshakeDetectionPresenter,
appErrorStateService = appErrorService,
analyticsService = FakeAnalyticsService(),
+ shareService = shareService,
sdkMetadata = FakeSdkMetadata("sha")
)
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
index 0a0b507742..aa0fe9cbf1 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
@@ -209,13 +209,33 @@ class IntentResolverTest {
permalinkParserResult = { permalinkData }
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
- action = Intent.ACTION_SEND
+ action = Intent.ACTION_BATTERY_LOW
data = "https://matrix.to/invalid".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
+ @Test
+ fun `test incoming share simple`() {
+ val sut = createIntentResolver()
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_SEND
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent))
+ }
+
+ @Test
+ fun `test incoming share multiple`() {
+ val sut = createIntentResolver()
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_SEND_MULTIPLE
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent))
+ }
+
@Test
fun `test resolve invalid`() {
val sut = createIntentResolver(
diff --git a/changelog.d/1980.feature b/changelog.d/1980.feature
new file mode 100644
index 0000000000..877ebe7e29
--- /dev/null
+++ b/changelog.d/1980.feature
@@ -0,0 +1 @@
+Add support for incoming share (text or files) from other apps
diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts
index b1060dcd0e..64c207c798 100644
--- a/features/securebackup/impl/build.gradle.kts
+++ b/features/securebackup/impl/build.gradle.kts
@@ -40,7 +40,6 @@ dependencies {
implementation(projects.anvilannotations)
implementation(projects.appconfig)
- implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
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/api/src/main/kotlin/io/element/android/features/share/api/ShareService.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareService.kt
new file mode 100644
index 0000000000..c46f5b3215
--- /dev/null
+++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareService.kt
@@ -0,0 +1,23 @@
+/*
+ * 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 kotlinx.coroutines.CoroutineScope
+
+interface ShareService {
+ fun observeFeatureFlag(coroutineScope: CoroutineScope)
+}
diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts
new file mode 100644
index 0000000000..c180c5d29a
--- /dev/null
+++ b/features/share/impl/build.gradle.kts
@@ -0,0 +1,70 @@
+/*
+ * 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.architecture)
+ implementation(projects.libraries.featureflag.api)
+ 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.libraries.mediaupload.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/DefaultShareService.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt
new file mode 100644
index 0000000000..2ea15a5d71
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.pm.PackageManager
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.share.api.ShareService
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import timber.log.Timber
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultShareService @Inject constructor(
+ private val featureFlagService: FeatureFlagService,
+ @ApplicationContext private val context: Context,
+) : ShareService {
+ override fun observeFeatureFlag(coroutineScope: CoroutineScope) {
+ val shareActivityComponent = getShareActivityComponent()
+ ?: return Unit.also {
+ Timber.w("ShareActivity not found")
+ }
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.IncomingShare)
+ .onEach { enabled ->
+ shareActivityComponent.enableOrDisable(enabled)
+ }
+ .launchIn(coroutineScope)
+ }
+
+ private fun getShareActivityComponent(): ComponentName? {
+ return context.packageManager
+ .getPackageInfo(
+ context.packageName,
+ PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS
+ )
+ .activities
+ .firstOrNull { it.name.endsWith(".ShareActivity") }
+ ?.let { shareActivityInfo ->
+ ComponentName(
+ shareActivityInfo.packageName,
+ shareActivityInfo.name,
+ )
+ }
+ }
+
+ private fun ComponentName.enableOrDisable(enabled: Boolean) {
+ val state = if (enabled) {
+ PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
+ } else {
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+ }
+ try {
+ context.packageManager.setComponentEnabledSetting(
+ this,
+ state,
+ PackageManager.DONT_KILL_APP,
+ )
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to enable or disable the component")
+ }
+ }
+}
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..9eaaf2b8f6
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt
@@ -0,0 +1,147 @@
+/*
+ * 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 android.os.Build
+import androidx.core.content.IntentCompat
+import com.squareup.anvil.annotations.ContributesBinding
+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 timber.log.Timber
+import javax.inject.Inject
+
+interface ShareIntentHandler {
+ data class UriToShare(
+ 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
+ */
+ suspend fun handleIncomingShareIntent(
+ intent: Intent,
+ onUris: suspend (List) -> Boolean,
+ onPlainText: suspend (String) -> Boolean,
+ ): Boolean
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultShareIntentHandler @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : ShareIntentHandler {
+ override suspend fun handleIncomingShareIntent(
+ intent: Intent,
+ onUris: 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() -> {
+ val uris = getIncomingUris(intent, type)
+ val result = onUris(uris)
+ revokeUriPermissions(uris.map { it.uri })
+ result
+ }
+ 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 getIncomingUris(intent: Intent, type: String): List {
+ val uriList = mutableListOf()
+ if (intent.action == Intent.ACTION_SEND) {
+ IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
+ ?.let { uriList.add(it) }
+ } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
+ IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
+ ?.let { uriList.addAll(it) }
+ }
+ val resInfoList: List = context.packageManager.queryIntentActivitiesCompat(intent, PackageManager.MATCH_DEFAULT_ONLY)
+ uriList.forEach { uri ->
+ resInfoList.forEach resolve@{ resolveInfo ->
+ 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, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ } catch (e: Exception) {
+ Timber.w(e, "Unable to grant Uri permission")
+ return@resolve
+ }
+ intent.action = null
+ intent.component = ComponentName(packageName, resolveInfo.activityInfo.name)
+ }
+ }
+ return uriList.map { uri ->
+ ShareIntentHandler.UriToShare(
+ uri = uri,
+ mimeType = type
+ )
+ }
+ }
+
+ private fun revokeUriPermissions(uris: List) {
+ uris.forEach { uri ->
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ } else {
+ context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ } catch (e: Exception) {
+ Timber.w(e, "Unable to revoke Uri permission")
+ }
+ }
+ }
+}
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..ad360b09ea
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt
@@ -0,0 +1,101 @@
+/*
+ * 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 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..c0ebb4ee16
--- /dev/null
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt
@@ -0,0 +1,115 @@
+/*
+ * 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)
+ }
+
+ @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,
+ ) = launch {
+ suspend {
+ val result = shareIntentHandler.handleIncomingShareIntent(
+ intent,
+ onUris = { filesToShare ->
+ if (filesToShare.isEmpty()) {
+ false
+ } else {
+ 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) {
+ error("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/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt
new file mode 100644
index 0000000000..682bb177cc
--- /dev/null
+++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt
@@ -0,0 +1,35 @@
+/*
+ * 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
+
+class FakeShareIntentHandler(
+ private val onIncomingShareIntent: suspend (
+ Intent,
+ suspend (List) -> Boolean,
+ suspend (String) -> Boolean,
+ ) -> Boolean = { _, _, _ -> false },
+) : ShareIntentHandler {
+ override suspend fun handleIncomingShareIntent(
+ intent: Intent,
+ onUris: suspend (List) -> Boolean,
+ onPlainText: suspend (String) -> Boolean,
+ ): Boolean {
+ return onIncomingShareIntent(intent, onUris, onPlainText)
+ }
+}
diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
new file mode 100644
index 0000000000..42c053bafb
--- /dev/null
+++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
@@ -0,0 +1,164 @@
+/*
+ * 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 android.net.Uri
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.test.A_MESSAGE
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SharePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createSharePresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.shareAction.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - on room selected error then clear error`() = runTest {
+ val presenter = createSharePresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.shareAction.isUninitialized()).isTrue()
+ presenter.onRoomSelected(listOf(A_ROOM_ID))
+ assertThat(awaitItem().shareAction.isLoading()).isTrue()
+ val failure = awaitItem()
+ assertThat(failure.shareAction.isFailure()).isTrue()
+ failure.eventSink.invoke(ShareEvents.ClearError)
+ assertThat(awaitItem().shareAction.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - on room selected ok`() = runTest {
+ val presenter = createSharePresenter(
+ shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true }
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.shareAction.isUninitialized()).isTrue()
+ presenter.onRoomSelected(listOf(A_ROOM_ID))
+ assertThat(awaitItem().shareAction.isLoading()).isTrue()
+ val success = awaitItem()
+ assertThat(success.shareAction.isSuccess()).isTrue()
+ assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
+ }
+ }
+
+ @Test
+ fun `present - send text ok`() = runTest {
+ val matrixRoom = FakeMatrixRoom()
+ val matrixClient = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, matrixRoom)
+ }
+ val presenter = createSharePresenter(
+ matrixClient = matrixClient,
+ shareIntentHandler = FakeShareIntentHandler { _, _, onText ->
+ onText(A_MESSAGE)
+ }
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.shareAction.isUninitialized()).isTrue()
+ presenter.onRoomSelected(listOf(A_ROOM_ID))
+ assertThat(awaitItem().shareAction.isLoading()).isTrue()
+ val success = awaitItem()
+ assertThat(success.shareAction.isSuccess()).isTrue()
+ assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
+ }
+ }
+
+ @Test
+ fun `present - send media ok`() = runTest {
+ val matrixRoom = FakeMatrixRoom()
+ val matrixClient = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, matrixRoom)
+ }
+ val presenter = createSharePresenter(
+ matrixClient = matrixClient,
+ shareIntentHandler = FakeShareIntentHandler { _, onFile, _ ->
+ onFile(
+ listOf(
+ ShareIntentHandler.UriToShare(
+ uri = Uri.parse("content://image.jpg"),
+ mimeType = MimeTypes.Jpeg,
+ )
+ )
+ )
+ }
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.shareAction.isUninitialized()).isTrue()
+ presenter.onRoomSelected(listOf(A_ROOM_ID))
+ assertThat(awaitItem().shareAction.isLoading()).isTrue()
+ val success = awaitItem()
+ assertThat(success.shareAction.isSuccess()).isTrue()
+ assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
+ }
+ }
+
+ private fun TestScope.createSharePresenter(
+ intent: Intent = Intent(),
+ shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor()
+ ): SharePresenter {
+ return SharePresenter(
+ intent = intent,
+ appCoroutineScope = this,
+ shareIntentHandler = shareIntentHandler,
+ matrixClient = matrixClient,
+ mediaPreProcessor = mediaPreProcessor
+ )
+ }
+}
diff --git a/features/share/test/build.gradle.kts b/features/share/test/build.gradle.kts
new file mode 100644
index 0000000000..0eaa0bedd2
--- /dev/null
+++ b/features/share/test/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.share.test"
+}
+
+dependencies {
+ implementation(projects.features.share.api)
+ implementation(libs.coroutines.core)
+ implementation(projects.tests.testutils)
+}
diff --git a/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareService.kt b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareService.kt
new file mode 100644
index 0000000000..302d8e2a54
--- /dev/null
+++ b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareService.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.test
+
+import io.element.android.features.share.api.ShareService
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.CoroutineScope
+
+class FakeShareService(
+ private val observeFeatureFlagLambda: (CoroutineScope) -> Unit = { lambdaError() }
+) : ShareService {
+ override fun observeFeatureFlag(coroutineScope: CoroutineScope) {
+ observeFeatureFlagLambda(coroutineScope)
+ }
+}
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..7fe91eb2e9 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,10 +16,22 @@
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
+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 {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApplicationInfo(
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 5c3bd8efd4..4d0c50a533 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -96,4 +96,11 @@ enum class FeatureFlags(
defaultValue = true,
isFinished = false,
),
+ IncomingShare(
+ key = "feature.incomingShare",
+ title = "Incoming Share support",
+ description = "Allow the application to receive data from other applications",
+ defaultValue = true,
+ isFinished = false,
+ ),
}
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
index 139877500b..7574144066 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
@@ -44,6 +44,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.RoomDirectorySearch -> false
FeatureFlags.ShowBlockedUsersDetails -> false
FeatureFlags.QrCodeLogin -> OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE
+ FeatureFlags.IncomingShare -> true
}
} else {
false
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"
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..1b6fb4bab8
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
+size 3642
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..89c8787a83
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a145061b3ac70800e68f4f7d5cb3377406c8da120e77a3c14bd0de581d32493c
+size 7285
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..1b6fb4bab8
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
+size 3642
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..84b1759702
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Day-0_1_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c261cfb0af17b3a4dad20b2717fca5c4d98ba8ae698c0b75630969d47cefb2ca
+size 8972
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..d6fd8eeb70
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
+size 3659
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..35de3d6199
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:09afce6e3c5975c3dc4cb14f17493a8385c3b616d5b6e9b7c66786156624d3d9
+size 6205
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..d6fd8eeb70
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
+size 3659
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..39888d1ecc
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.share.impl_ShareView_null_ShareView-Night-0_2_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:841d5dba36d0221f0893e0f0259c8e5cab3b7fc6d6c9b0d4f6e8b96accc7446b
+size 7600
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.roomselect.impl_RoomSelectView_null_RoomSelectView-Day-0_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.roomselect.impl_RoomSelectView_null_RoomSelectView-Day-0_1_null_5,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..720ee4d1bc
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.roomselect.impl_RoomSelectView_null_RoomSelectView-Day-0_1_null_5,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a26f6207a1509809313c381c5b0f35e72d584a1323037d1f224583f95295a2b0
+size 27896
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.roomselect.impl_RoomSelectView_null_RoomSelectView-Night-0_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.roomselect.impl_RoomSelectView_null_RoomSelectView-Night-0_2_null_5,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..7b6350a3a0
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.roomselect.impl_RoomSelectView_null_RoomSelectView-Night-0_2_null_5,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a969b67571558b1e2fe4192ac0896b72ba6f8234542fd812cb3a4e231625d6a1
+size 27120