From c403dcd5da65384149f1ebdf1664acab60740dd5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Jun 2024 18:02:48 +0200 Subject: [PATCH 01/16] Incoming share --- app/src/main/AndroidManifest.xml | 16 +++ appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInFlowNode.kt | 31 +++++ .../io/element/android/appnav/RootFlowNode.kt | 13 ++ .../android/appnav/intent/IntentResolver.kt | 5 + features/share/api/build.gradle.kts | 29 ++++ .../features/share/api/ShareEntryPoint.kt | 41 ++++++ features/share/impl/build.gradle.kts | 69 ++++++++++ .../share/impl/DefaultShareEntryPoint.kt | 49 +++++++ .../features/share/impl/ShareEvents.kt | 21 +++ .../features/share/impl/ShareIntentHandler.kt | 126 ++++++++++++++++++ .../android/features/share/impl/ShareNode.kt | 101 ++++++++++++++ .../features/share/impl/SharePresenter.kt | 112 ++++++++++++++++ .../android/features/share/impl/ShareState.kt | 25 ++++ .../features/share/impl/ShareStateProvider.kt | 47 +++++++ .../android/features/share/impl/ShareView.kt | 49 +++++++ .../libraries/androidutils/compat/Compat.kt | 23 ++++ .../roomselect/api/RoomSelectMode.kt | 1 + .../impl/RoomSelectStateProvider.kt | 16 ++- .../roomselect/impl/RoomSelectView.kt | 1 + .../src/main/res/values/localazy.xml | 1 + 21 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 features/share/api/build.gradle.kts create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt create mode 100644 features/share/impl/build.gradle.kts create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 931c995d9a..8c9a247172 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,6 +120,22 @@ + + + + + + + + + + + + + + + + ( @@ -219,6 +222,9 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize data object RoomDirectorySearch : NavTarget + + @Parcelize + data class IncomingShare(val intent: Intent) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -375,6 +381,20 @@ class LoggedInFlowNode @AssistedInject constructor( }) .build() } + is NavTarget.IncomingShare -> { + shareEntryPoint.nodeBuilder(this, buildContext) + .callback(object : ShareEntryPoint.Callback { + override fun onDone(roomIds: List) { + navigateUp() + if (roomIds.size == 1) { + val targetRoomId = roomIds.first() + backstack.push(NavTarget.Room(targetRoomId.toRoomIdOrAlias())) + } + } + }) + .params(ShareEntryPoint.Params(intent = navTarget.intent)) + .build() + } } } @@ -414,6 +434,17 @@ class LoggedInFlowNode @AssistedInject constructor( } } + internal suspend fun attachIncomingShare(intent: Intent) { + waitForNavTargetAttached { navTarget -> + navTarget is NavTarget.RoomList + } + attachChild { + backstack.push( + NavTarget.IncomingShare(intent) + ) + } + } + @Composable override fun View(modifier: Modifier) { Box(modifier = modifier) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 4b886d9c99..d200acb84e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -283,6 +283,19 @@ class RootFlowNode @AssistedInject constructor( is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) + is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent) + } + } + + private suspend fun onIncomingShare(intent: Intent) { + // Is there a session already? + val latestSessionId = authenticationService.getLatestSessionId() + if (latestSessionId == null) { + // No session, open login + switchToNotLoggedInFlow() + } else { + attachSession(latestSessionId) + .attachIncomingShare(intent) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt index dafbf8c283..d41fbbfebb 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -30,6 +30,7 @@ sealed interface ResolvedIntent { data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent data class Oidc(val oidcAction: OidcAction) : ResolvedIntent data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent + data class IncomingShare(val intent: Intent) : ResolvedIntent } class IntentResolver @Inject constructor( @@ -56,6 +57,10 @@ class IntentResolver @Inject constructor( ?.takeIf { it !is PermalinkData.FallbackLink } if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) + if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { + return ResolvedIntent.IncomingShare(intent) + } + // Unknown intent Timber.w("Unknown intent") return null diff --git a/features/share/api/build.gradle.kts b/features/share/api/build.gradle.kts new file mode 100644 index 0000000000..14528434a6 --- /dev/null +++ b/features/share/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.share.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt new file mode 100644 index 0000000000..0861e00ca2 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.api + +import android.content.Intent +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId + +interface ShareEntryPoint : FeatureEntryPoint { + data class Params(val intent: Intent) : NodeInputs + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface Callback : Plugin { + fun onDone(roomIds: List) + } + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } +} diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts new file mode 100644 index 0000000000..5da977b4c0 --- /dev/null +++ b/features/share/impl/build.gradle.kts @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.share.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.appconfig) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + api(libs.statemachine) + api(projects.features.share.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) + + ksp(libs.showkase.processor) +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt new file mode 100644 index 0000000000..95859cc8b0 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultShareEntryPoint @Inject constructor() : ShareEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ShareEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : ShareEntryPoint.NodeBuilder { + override fun params(params: ShareEntryPoint.Params): ShareEntryPoint.NodeBuilder { + plugins += ShareNode.Inputs(intent = params.intent) + return this + } + + override fun callback(callback: ShareEntryPoint.Callback): ShareEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt new file mode 100644 index 0000000000..9fe24660ec --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +sealed interface ShareEvents { + data object ClearError : ShareEvents +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt new file mode 100644 index 0000000000..91ec95cda2 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.compat.getParcelableArrayListExtraCompat +import io.element.android.libraries.androidutils.compat.getParcelableExtraCompat +import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeApplication +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeFile +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeText +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +interface ShareIntentHandler { + suspend fun handleIncomingShareIntent( + intent: Intent, + onFile: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultShareIntentHandler @Inject constructor( + @ApplicationContext private val context: Context, +) : ShareIntentHandler { + data class FileToShare( + val uri: Uri, + val mimeType: String, + ) + + /** + * This methods aims to handle incoming share intents. + * + * @return true if it can handle the intent data, false otherwise + */ + override suspend fun handleIncomingShareIntent( + intent: Intent, + onFile: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean { + val type = intent.resolveType(context) ?: return false + return when { + type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText) + type.isMimeTypeImage() || + type.isMimeTypeVideo() || + type.isMimeTypeAudio() || + type.isMimeTypeApplication() || + type.isMimeTypeFile() || + type.isMimeTypeText() || + type.isMimeTypeAny() -> onFile(getIncomingFiles(intent, type)) + else -> false + } + } + + private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean { + val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + return if (content?.isNotEmpty() == true) { + onPlainText(content) + } else { + false + } + } + + /** + * Use this function to retrieve files which are shared from another application or internally + * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. + */ + private fun getIncomingFiles(data: Intent, type: String): List { + val uriList = mutableListOf() + if (data.action == Intent.ACTION_SEND) { + data.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uriList.add(it) } + } else if (data.action == Intent.ACTION_SEND_MULTIPLE) { + val extraUriList: List? = data.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) + extraUriList?.let { uriList.addAll(it) } + } + val resInfoList: List = context.packageManager.queryIntentActivitiesCompat(data, PackageManager.MATCH_DEFAULT_ONLY) + uriList.forEach { + for (resolveInfo in resInfoList) { + val packageName: String = resolveInfo.activityInfo.packageName + // Replace implicit intent by an explicit to fix crash on some devices like Xiaomi. + // see https://juejin.cn/post/7031736325422186510 + try { + context.grantUriPermission(packageName, it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: Exception) { + continue + } + data.action = null + data.component = ComponentName(packageName, resolveInfo.activityInfo.name) + break + } + } + return uriList.map { uri -> + FileToShare( + uri = uri, + mimeType = type + ) + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt new file mode 100644 index 0000000000..85041a8c97 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 20244 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +import android.content.Intent +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class ShareNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SharePresenter.Factory, + private val roomSelectEntryPoint: RoomSelectEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + @Parcelize + object NavTarget : Parcelable + + data class Inputs(val intent: Intent) : NodeInputs + + private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.intent) + private val callbacks = plugins.filterIsInstance() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + val callback = object : RoomSelectEntryPoint.Callback { + override fun onRoomSelected(roomIds: List) { + presenter.onRoomSelected(roomIds) + } + + override fun onCancel() { + navigateUp() + } + } + + return roomSelectEntryPoint.nodeBuilder(this, buildContext) + .callback(callback) + .params(RoomSelectEntryPoint.Params(mode = RoomSelectMode.Share)) + .build() + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + // Will render to room select screen + Children( + navModel = navModel, + ) + + val state = presenter.present() + ShareView( + state = state, + onShareSuccess = ::onShareSuccess, + ) + } + } + + private fun onShareSuccess(roomIds: List) { + callbacks.forEach { it.onDone(roomIds) } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt new file mode 100644 index 0000000000..47513e282b --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSender +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class SharePresenter @AssistedInject constructor( + @Assisted private val intent: Intent, + private val appCoroutineScope: CoroutineScope, + private val shareIntentHandler: ShareIntentHandler, + private val matrixClient: MatrixClient, + private val mediaPreProcessor: MediaPreProcessor, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(intent: Intent): SharePresenter + } + + private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) + + fun onRoomSelected(roomIds: List) { + appCoroutineScope.share(intent, roomIds, shareActionState) + } + + @Composable + override fun present(): ShareState { + fun handleEvents(event: ShareEvents) { + when (event) { + ShareEvents.ClearError -> shareActionState.value = AsyncAction.Uninitialized + } + } + + return ShareState( + shareAction = shareActionState.value, + eventSink = { handleEvents(it) } + ) + } + + private fun CoroutineScope.share( + intent: Intent, + roomIds: List, + shareActionState: MutableState>>, + ) = launch { + suspend { + val result = shareIntentHandler.handleIncomingShareIntent( + intent, + onFile = { filesToShare -> + roomIds + .map { roomId -> + val room = matrixClient.getRoom(roomId) ?: return@map false + val mediaSender = MediaSender(preProcessor = mediaPreProcessor, room = room) + filesToShare + .map { fileToShare -> + mediaSender.sendMedia( + uri = fileToShare.uri, + mimeType = fileToShare.mimeType, + compressIfPossible = true, + ).isSuccess + } + .all { it } + } + .all { it } + }, + onPlainText = { text -> + roomIds + .map { roomId -> + matrixClient.getRoom(roomId)?.sendMessage( + body = text, + htmlBody = null, + mentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + ) + if (!result) { + throw Exception("Failed to handle incoming share intent") + } + roomIds + }.runCatchingUpdatingState(shareActionState) + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt new file mode 100644 index 0000000000..b7e3510033 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +data class ShareState( + val shareAction: AsyncAction>, + val eventSink: (ShareEvents) -> Unit +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt new file mode 100644 index 0000000000..a8b766f238 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +open class ShareStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aShareState(), + aShareState( + shareAction = AsyncAction.Loading, + ), + aShareState( + shareAction = AsyncAction.Success( + listOf(RoomId("!room2:domain")), + ) + ), + aShareState( + shareAction = AsyncAction.Failure(Throwable("error")), + ), + ) +} + +fun aShareState( + shareAction: AsyncAction> = AsyncAction.Uninitialized, + eventSink: (ShareEvents) -> Unit = {} +) = ShareState( + shareAction = shareAction, + eventSink = eventSink +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt new file mode 100644 index 0000000000..1bc3e2325b --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.share.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.RoomId + +@Composable +fun ShareView( + state: ShareState, + onShareSuccess: (List) -> Unit, +) { + AsyncActionView( + async = state.shareAction, + onSuccess = { + onShareSuccess(it) + }, + onErrorDismiss = { + state.eventSink(ShareEvents.ClearError) + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun ShareViewPreview(@PreviewParameter(ShareStateProvider::class) state: ShareState) = ElementPreview { + ShareView( + state = state, + onShareSuccess = {} + ) +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt index 3c897b3d88..2bc5380156 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt @@ -16,9 +16,32 @@ package io.element.android.libraries.androidutils.compat +import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.content.pm.ResolveInfo import android.os.Build +import android.os.Parcelable + +inline fun Intent.getParcelableExtraCompat(key: String): T? = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelableExtra(key, T::class.java) + else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T? +} + +inline fun Intent.getParcelableArrayListExtraCompat(key: String): ArrayList? = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelableArrayListExtra(key, T::class.java) + else -> @Suppress("DEPRECATION") getParcelableArrayListExtra(key) +} + +fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> queryIntentActivities( + data, + PackageManager.ResolveInfoFlags.of(flags.toLong()) + ) + else -> @Suppress("DEPRECATION") queryIntentActivities(data, flags) + } +} fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo { return when { diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt index d3f63e366c..bff273aa9f 100644 --- a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt @@ -18,4 +18,5 @@ package io.element.android.libraries.roomselect.api enum class RoomSelectMode { Forward, + Share, } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt index 7bfddeb280..2821efd36a 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt @@ -31,29 +31,33 @@ open class RoomSelectStateProvider : PreviewParameterProvider { get() = sequenceOf( aRoomSelectState(), aRoomSelectState(query = "Test", isSearchActive = true), - aRoomSelectState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), + aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), aRoomSelectState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), query = "Test", isSearchActive = true, ), aRoomSelectState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), query = "Test", isSearchActive = true, selectedRooms = persistentListOf(aRoomSummaryDetails(roomId = RoomId("!room2:domain"))) ), - // Add other states here + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + ), ) } private fun aRoomSelectState( + mode: RoomSelectMode = RoomSelectMode.Forward, resultState: SearchBarResultState> = SearchBarResultState.Initial(), query: String = "", isSearchActive: Boolean = false, selectedRooms: ImmutableList = persistentListOf(), ) = RoomSelectState( - mode = RoomSelectMode.Forward, + mode = mode, resultState = resultState, query = query, isSearchActive = isSearchActive, @@ -61,7 +65,7 @@ private fun aRoomSelectState( eventSink = {} ) -private fun aForwardMessagesRoomList() = persistentListOf( +private fun aRoomSelectRoomList() = persistentListOf( aRoomSummaryDetails(), aRoomSummaryDetails( roomId = RoomId("!room2:domain"), diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 438cb8d4e6..fa8525bdc1 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -105,6 +105,7 @@ fun RoomSelectView( Text( text = when (state.mode) { RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) + RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to) }, style = ElementTheme.typography.aliasScreenTitle ) diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 8d46b6e5b1..a106be8b5a 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -197,6 +197,7 @@ "Search results" "Security" "Seen by" + "Send to" "Sending…" "Sending failed" "Sent" From 6a10f38f68a3eebf8bae90b3aa7fa3e3b31e10d3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Jun 2024 18:13:23 +0200 Subject: [PATCH 02/16] Remove duplicated dependency --- features/securebackup/impl/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) 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) From fcd51d5647707223e732d9f0ffd2f38b6de088e3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Jun 2024 18:23:15 +0200 Subject: [PATCH 03/16] Add test on SharePresenter --- features/share/impl/build.gradle.kts | 2 +- .../share/impl/FakeShareIntentHandler.kt | 35 ++++ .../features/share/impl/SharePresenterTest.kt | 164 ++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt create mode 100644 features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 5da977b4c0..bc9fdee42d 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -42,7 +42,6 @@ dependencies { implementation(projects.appconfig) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) - implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) @@ -62,6 +61,7 @@ dependencies { 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) 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..8951ab4214 --- /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, + onFile: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean { + return onIncomingShareIntent(intent, onFile, 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..1ea4426a2e --- /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( + DefaultShareIntentHandler.FileToShare( + 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 + ) + } +} From 4bac15609e0ba1f99932c98cfcdb26604d9acbd1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Jun 2024 18:39:33 +0200 Subject: [PATCH 04/16] changelog --- changelog.d/1980.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1980.feature 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 From 5a19231be4588fcf299300147fa6d66f9b11be1d Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 5 Jun 2024 16:55:03 +0000 Subject: [PATCH 05/16] Update screenshots --- ...ShareView_null_ShareView-Day-0_1_null_0,NEXUS_5,1.0,en].png | 3 +++ ...ShareView_null_ShareView-Day-0_1_null_1,NEXUS_5,1.0,en].png | 3 +++ ...ShareView_null_ShareView-Day-0_1_null_2,NEXUS_5,1.0,en].png | 3 +++ ...ShareView_null_ShareView-Day-0_1_null_3,NEXUS_5,1.0,en].png | 3 +++ ...areView_null_ShareView-Night-0_2_null_0,NEXUS_5,1.0,en].png | 3 +++ ...areView_null_ShareView-Night-0_2_null_1,NEXUS_5,1.0,en].png | 3 +++ ...areView_null_ShareView-Night-0_2_null_2,NEXUS_5,1.0,en].png | 3 +++ ...areView_null_ShareView-Night-0_2_null_3,NEXUS_5,1.0,en].png | 3 +++ ...View_null_RoomSelectView-Day-0_1_null_5,NEXUS_5,1.0,en].png | 3 +++ ...ew_null_RoomSelectView-Night-0_2_null_5,NEXUS_5,1.0,en].png | 3 +++ 10 files changed, 30 insertions(+) create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 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 From ebe7ab2d2b591117499169e85f36f427508be194 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Jun 2024 18:46:14 +0200 Subject: [PATCH 06/16] Fix test and add new tests. --- .../appnav/intent/IntentResolverTest.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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( From 32ab6639f72fcd8c2181244c8603e791ac133a71 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Jun 2024 18:52:57 +0200 Subject: [PATCH 07/16] Fix quality issues --- .../android/features/share/impl/ShareIntentHandler.kt | 5 ++--- .../io/element/android/features/share/impl/ShareNode.kt | 2 +- .../io/element/android/features/share/impl/SharePresenter.kt | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) 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 index 91ec95cda2..2a0f3bb04c 100644 --- 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 @@ -102,18 +102,17 @@ class DefaultShareIntentHandler @Inject constructor( } val resInfoList: List = context.packageManager.queryIntentActivitiesCompat(data, PackageManager.MATCH_DEFAULT_ONLY) uriList.forEach { - for (resolveInfo in resInfoList) { + 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, it, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (e: Exception) { - continue + return@resolve } data.action = null data.component = ComponentName(packageName, resolveInfo.activityInfo.name) - break } } return uriList.map { uri -> 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 index 85041a8c97..ad360b09ea 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 20244 New Vector Ltd + * 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. 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 index 47513e282b..985e1fab12 100644 --- 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 @@ -104,7 +104,7 @@ class SharePresenter @AssistedInject constructor( } ) if (!result) { - throw Exception("Failed to handle incoming share intent") + error("Failed to handle incoming share intent") } roomIds }.runCatchingUpdatingState(shareActionState) From 37934eee7e02d3990c1d7dfd9177b6bf8f759d09 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 09:36:45 +0200 Subject: [PATCH 08/16] Ensure Uri permission is revoked. --- .../features/share/impl/ShareIntentHandler.kt | 28 +++++++++++++++++-- .../features/share/impl/SharePresenter.kt | 2 +- .../share/impl/FakeShareIntentHandler.kt | 4 +-- 3 files changed, 28 insertions(+), 6 deletions(-) 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 index 2a0f3bb04c..aa86f00c4f 100644 --- 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 @@ -22,6 +22,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.net.Uri +import android.os.Build import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.androidutils.compat.getParcelableArrayListExtraCompat import io.element.android.libraries.androidutils.compat.getParcelableExtraCompat @@ -36,12 +37,13 @@ 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 { suspend fun handleIncomingShareIntent( intent: Intent, - onFile: suspend (List) -> Boolean, + onFiles: suspend (List) -> Boolean, onPlainText: suspend (String) -> Boolean, ): Boolean } @@ -62,7 +64,7 @@ class DefaultShareIntentHandler @Inject constructor( */ override suspend fun handleIncomingShareIntent( intent: Intent, - onFile: suspend (List) -> Boolean, + onFiles: suspend (List) -> Boolean, onPlainText: suspend (String) -> Boolean, ): Boolean { val type = intent.resolveType(context) ?: return false @@ -74,7 +76,12 @@ class DefaultShareIntentHandler @Inject constructor( type.isMimeTypeApplication() || type.isMimeTypeFile() || type.isMimeTypeText() || - type.isMimeTypeAny() -> onFile(getIncomingFiles(intent, type)) + type.isMimeTypeAny() -> { + val files = getIncomingFiles(intent, type) + val result = onFiles(files) + revokeUriPermissions(files.map { it.uri }) + result + } else -> false } } @@ -109,6 +116,7 @@ class DefaultShareIntentHandler @Inject constructor( try { context.grantUriPermission(packageName, it, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (e: Exception) { + Timber.w(e, "Unable to grant Uri permission") return@resolve } data.action = null @@ -122,4 +130,18 @@ class DefaultShareIntentHandler @Inject constructor( ) } } + + 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/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 985e1fab12..00fd203dec 100644 --- 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 @@ -74,7 +74,7 @@ class SharePresenter @AssistedInject constructor( suspend { val result = shareIntentHandler.handleIncomingShareIntent( intent, - onFile = { filesToShare -> + onFiles = { filesToShare -> roomIds .map { roomId -> val room = matrixClient.getRoom(roomId) ?: return@map false 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 index 8951ab4214..ba0d65a4f7 100644 --- 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 @@ -27,9 +27,9 @@ class FakeShareIntentHandler( ) : ShareIntentHandler { override suspend fun handleIncomingShareIntent( intent: Intent, - onFile: suspend (List) -> Boolean, + onFiles: suspend (List) -> Boolean, onPlainText: suspend (String) -> Boolean, ): Boolean { - return onIncomingShareIntent(intent, onFile, onPlainText) + return onIncomingShareIntent(intent, onFiles, onPlainText) } } From 64cb42af75625096340c3310148136c49b8ea3b6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 09:40:54 +0200 Subject: [PATCH 09/16] Rename classes for clarity. --- .../features/share/impl/ShareIntentHandler.kt | 38 +++++++++---------- .../features/share/impl/SharePresenter.kt | 2 +- .../share/impl/FakeShareIntentHandler.kt | 6 +-- .../features/share/impl/SharePresenterTest.kt | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) 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 index aa86f00c4f..6ef7500cb2 100644 --- 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 @@ -41,9 +41,14 @@ import timber.log.Timber import javax.inject.Inject interface ShareIntentHandler { + data class UriToShare( + val uri: Uri, + val mimeType: String, + ) + suspend fun handleIncomingShareIntent( intent: Intent, - onFiles: suspend (List) -> Boolean, + onUris: suspend (List) -> Boolean, onPlainText: suspend (String) -> Boolean, ): Boolean } @@ -52,11 +57,6 @@ interface ShareIntentHandler { class DefaultShareIntentHandler @Inject constructor( @ApplicationContext private val context: Context, ) : ShareIntentHandler { - data class FileToShare( - val uri: Uri, - val mimeType: String, - ) - /** * This methods aims to handle incoming share intents. * @@ -64,7 +64,7 @@ class DefaultShareIntentHandler @Inject constructor( */ override suspend fun handleIncomingShareIntent( intent: Intent, - onFiles: suspend (List) -> Boolean, + onUris: suspend (List) -> Boolean, onPlainText: suspend (String) -> Boolean, ): Boolean { val type = intent.resolveType(context) ?: return false @@ -78,7 +78,7 @@ class DefaultShareIntentHandler @Inject constructor( type.isMimeTypeText() || type.isMimeTypeAny() -> { val files = getIncomingFiles(intent, type) - val result = onFiles(files) + val result = onUris(files) revokeUriPermissions(files.map { it.uri }) result } @@ -99,32 +99,32 @@ class DefaultShareIntentHandler @Inject constructor( * Use this function to retrieve files which are shared from another application or internally * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. */ - private fun getIncomingFiles(data: Intent, type: String): List { + private fun getIncomingFiles(intent: Intent, type: String): List { val uriList = mutableListOf() - if (data.action == Intent.ACTION_SEND) { - data.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uriList.add(it) } - } else if (data.action == Intent.ACTION_SEND_MULTIPLE) { - val extraUriList: List? = data.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) + if (intent.action == Intent.ACTION_SEND) { + intent.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uriList.add(it) } + } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { + val extraUriList: List? = intent.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) extraUriList?.let { uriList.addAll(it) } } - val resInfoList: List = context.packageManager.queryIntentActivitiesCompat(data, PackageManager.MATCH_DEFAULT_ONLY) - uriList.forEach { + 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, it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (e: Exception) { Timber.w(e, "Unable to grant Uri permission") return@resolve } - data.action = null - data.component = ComponentName(packageName, resolveInfo.activityInfo.name) + intent.action = null + intent.component = ComponentName(packageName, resolveInfo.activityInfo.name) } } return uriList.map { uri -> - FileToShare( + ShareIntentHandler.UriToShare( uri = uri, mimeType = type ) 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 index 00fd203dec..8a5ecbd421 100644 --- 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 @@ -74,7 +74,7 @@ class SharePresenter @AssistedInject constructor( suspend { val result = shareIntentHandler.handleIncomingShareIntent( intent, - onFiles = { filesToShare -> + onUris = { filesToShare -> roomIds .map { roomId -> val room = matrixClient.getRoom(roomId) ?: return@map false 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 index ba0d65a4f7..682bb177cc 100644 --- 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 @@ -21,15 +21,15 @@ import android.content.Intent class FakeShareIntentHandler( private val onIncomingShareIntent: suspend ( Intent, - suspend (List) -> Boolean, + suspend (List) -> Boolean, suspend (String) -> Boolean, ) -> Boolean = { _, _, _ -> false }, ) : ShareIntentHandler { override suspend fun handleIncomingShareIntent( intent: Intent, - onFiles: suspend (List) -> Boolean, + onUris: suspend (List) -> Boolean, onPlainText: suspend (String) -> Boolean, ): Boolean { - return onIncomingShareIntent(intent, onFiles, onPlainText) + 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 index 1ea4426a2e..42c053bafb 100644 --- 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 @@ -126,7 +126,7 @@ class SharePresenterTest { shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> onFile( listOf( - DefaultShareIntentHandler.FileToShare( + ShareIntentHandler.UriToShare( uri = Uri.parse("content://image.jpg"), mimeType = MimeTypes.Jpeg, ) From 767e1b42596f4e3ed609fa2011b868d7182d9d7c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 09:44:57 +0200 Subject: [PATCH 10/16] Handle no uri to share case. --- .../features/share/impl/SharePresenter.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) 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 index 8a5ecbd421..07668a6628 100644 --- 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 @@ -75,21 +75,25 @@ class SharePresenter @AssistedInject constructor( val result = shareIntentHandler.handleIncomingShareIntent( intent, onUris = { filesToShare -> - roomIds - .map { roomId -> - val room = matrixClient.getRoom(roomId) ?: return@map false - val mediaSender = MediaSender(preProcessor = mediaPreProcessor, room = room) - filesToShare - .map { fileToShare -> - mediaSender.sendMedia( - uri = fileToShare.uri, - mimeType = fileToShare.mimeType, - compressIfPossible = true, - ).isSuccess - } - .all { it } - } - .all { it } + 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 From 9042b8e2ad11aeefd96e0a96db95e09b306aeddd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 09:50:23 +0200 Subject: [PATCH 11/16] More renaming --- .../android/features/share/impl/ShareIntentHandler.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 6ef7500cb2..f2871d4393 100644 --- 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 @@ -77,9 +77,9 @@ class DefaultShareIntentHandler @Inject constructor( type.isMimeTypeFile() || type.isMimeTypeText() || type.isMimeTypeAny() -> { - val files = getIncomingFiles(intent, type) - val result = onUris(files) - revokeUriPermissions(files.map { it.uri }) + val uris = getIncomingUris(intent, type) + val result = onUris(uris) + revokeUriPermissions(uris.map { it.uri }) result } else -> false @@ -99,7 +99,7 @@ class DefaultShareIntentHandler @Inject constructor( * Use this function to retrieve files which are shared from another application or internally * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. */ - private fun getIncomingFiles(intent: Intent, type: String): List { + private fun getIncomingUris(intent: Intent, type: String): List { val uriList = mutableListOf() if (intent.action == Intent.ACTION_SEND) { intent.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uriList.add(it) } From df1f5ec3134685c9e2d21c85ca7e03d56629a45b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 09:51:40 +0200 Subject: [PATCH 12/16] Move doc to the interface. --- .../android/features/share/impl/ShareIntentHandler.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index f2871d4393..aeeec7abf8 100644 --- 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 @@ -46,6 +46,11 @@ interface ShareIntentHandler { 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, @@ -57,11 +62,6 @@ interface ShareIntentHandler { class DefaultShareIntentHandler @Inject constructor( @ApplicationContext private val context: Context, ) : ShareIntentHandler { - /** - * This methods aims to handle incoming share intents. - * - * @return true if it can handle the intent data, false otherwise - */ override suspend fun handleIncomingShareIntent( intent: Intent, onUris: suspend (List) -> Boolean, From ffff1c904ff3a6ce7b5a7fc18be4fd1664d28142 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 09:56:36 +0200 Subject: [PATCH 13/16] Use functions from IntentCompat --- .../android/features/share/impl/ShareIntentHandler.kt | 10 +++++----- .../android/libraries/androidutils/compat/Compat.kt | 11 ----------- 2 files changed, 5 insertions(+), 16 deletions(-) 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 index aeeec7abf8..9eaaf2b8f6 100644 --- 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 @@ -23,9 +23,8 @@ 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.getParcelableArrayListExtraCompat -import io.element.android.libraries.androidutils.compat.getParcelableExtraCompat import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny @@ -102,10 +101,11 @@ class DefaultShareIntentHandler @Inject constructor( private fun getIncomingUris(intent: Intent, type: String): List { val uriList = mutableListOf() if (intent.action == Intent.ACTION_SEND) { - intent.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uriList.add(it) } + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?.let { uriList.add(it) } } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { - val extraUriList: List? = intent.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) - extraUriList?.let { uriList.addAll(it) } + 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 -> 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 2bc5380156..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 @@ -21,17 +21,6 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.os.Build -import android.os.Parcelable - -inline fun Intent.getParcelableExtraCompat(key: String): T? = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelableExtra(key, T::class.java) - else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T? -} - -inline fun Intent.getParcelableArrayListExtraCompat(key: String): ArrayList? = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelableArrayListExtra(key, T::class.java) - else -> @Suppress("DEPRECATION") getParcelableArrayListExtra(key) -} fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List { return when { From abaac1c5caf6d6a21f541bce35cd169b549a444f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 10:02:35 +0200 Subject: [PATCH 14/16] shareActionState is a property, no need to provide it as parameter. --- .../io/element/android/features/share/impl/SharePresenter.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 07668a6628..c0ebb4ee16 100644 --- 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 @@ -49,7 +49,7 @@ class SharePresenter @AssistedInject constructor( private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) fun onRoomSelected(roomIds: List) { - appCoroutineScope.share(intent, roomIds, shareActionState) + appCoroutineScope.share(intent, roomIds) } @Composable @@ -69,7 +69,6 @@ class SharePresenter @AssistedInject constructor( private fun CoroutineScope.share( intent: Intent, roomIds: List, - shareActionState: MutableState>>, ) = launch { suspend { val result = shareIntentHandler.handleIncomingShareIntent( From 6781b5cee7779d0d50019b07477d5c3f23c71d22 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 12:17:50 +0200 Subject: [PATCH 15/16] Add a flag to enable or disable incoming share --- app/src/main/AndroidManifest.xml | 12 ++- appnav/build.gradle.kts | 1 + .../android/appnav/root/RootPresenter.kt | 6 ++ .../android/appnav/RootPresenterTest.kt | 24 +++++- .../features/share/api/ShareService.kt | 23 ++++++ features/share/impl/build.gradle.kts | 1 + .../share/impl/DefaultShareService.kt | 76 +++++++++++++++++++ features/share/test/build.gradle.kts | 28 +++++++ .../features/share/test/FakeShareService.kt | 29 +++++++ .../libraries/featureflag/api/FeatureFlags.kt | 7 ++ .../impl/StaticFeatureFlagProvider.kt | 1 + 11 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareService.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt create mode 100644 features/share/test/build.gradle.kts create mode 100644 features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c9a247172..b3872a18fd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,10 +120,18 @@ + + + + - + @@ -136,7 +144,7 @@ - + { @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/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 index bc9fdee42d..c180c5d29a 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { 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) 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..89a94c2a95 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt @@ -0,0 +1,76 @@ +/* + * 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 = 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, + ) + } + ?: return Unit.also { + Timber.w("ShareActivity not found") + } + featureFlagService.isFeatureEnabledFlow(FeatureFlags.IncomingShare) + .onEach { enabled -> + val state = if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + try { + context.packageManager.setComponentEnabledSetting( + shareActivityComponent, + state, + PackageManager.DONT_KILL_APP, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to enable or disable the component") + } + } + .launchIn(coroutineScope) + } +} 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/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 From 13d46e0e7800a588a1e8498b70ecf870a69fbb3a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Jun 2024 13:06:34 +0200 Subject: [PATCH 16/16] Extract code in private sub methods --- .../share/impl/DefaultShareService.kt | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) 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 index 89a94c2a95..2ea15a5d71 100644 --- 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 @@ -37,8 +37,19 @@ class DefaultShareService @Inject constructor( @ApplicationContext private val context: Context, ) : ShareService { override fun observeFeatureFlag(coroutineScope: CoroutineScope) { - val shareActivityComponent = context - .packageManager + 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 @@ -51,26 +62,22 @@ class DefaultShareService @Inject constructor( shareActivityInfo.name, ) } - ?: return Unit.also { - Timber.w("ShareActivity not found") - } - featureFlagService.isFeatureEnabledFlow(FeatureFlags.IncomingShare) - .onEach { enabled -> - val state = if (enabled) { - PackageManager.COMPONENT_ENABLED_STATE_DEFAULT - } else { - PackageManager.COMPONENT_ENABLED_STATE_DISABLED - } - try { - context.packageManager.setComponentEnabledSetting( - shareActivityComponent, - state, - PackageManager.DONT_KILL_APP, - ) - } catch (e: Exception) { - Timber.e(e, "Failed to enable or disable the component") - } - } - .launchIn(coroutineScope) + } + + 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") + } } }