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"