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