Merge pull request #2984 from element-hq/feature/bma/incomingShare
Incoming share
This commit is contained in:
@@ -122,6 +122,30 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Using an activity-alias for incoming share intent, in order
|
||||
to be able to disable the feature programmatically -->
|
||||
<activity-alias
|
||||
android:name=".ShareActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".MainActivity">
|
||||
<!-- Incoming share simple -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<data android:mimeType="*/*" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.OPENABLE" />
|
||||
</intent-filter>
|
||||
<!-- Incoming share multiple -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<data android:mimeType="*/*" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.OPENABLE" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
|
||||
@@ -52,6 +52,7 @@ dependencies {
|
||||
implementation(libs.coil)
|
||||
|
||||
implementation(projects.features.ftue.api)
|
||||
implementation(projects.features.share.api)
|
||||
implementation(projects.features.viewfolder.api)
|
||||
|
||||
implementation(projects.services.apperror.impl)
|
||||
@@ -71,6 +72,7 @@ dependencies {
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.features.rageshake.test)
|
||||
testImplementation(projects.features.rageshake.impl)
|
||||
testImplementation(projects.features.share.test)
|
||||
testImplementation(projects.services.appnavstate.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(libs.test.appyx.junit)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.appnav
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -54,6 +55,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||
import io.element.android.features.share.api.ShareEntryPoint
|
||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
@@ -98,6 +100,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val ftueService: FtueService,
|
||||
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
||||
private val shareEntryPoint: ShareEntryPoint,
|
||||
private val matrixClient: MatrixClient,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
@@ -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<RoomId>) {
|
||||
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<Node> {
|
||||
backstack.push(
|
||||
NavTarget.IncomingShare(intent)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
|
||||
import im.vector.app.features.analytics.plan.SuperProperties
|
||||
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
|
||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
|
||||
import io.element.android.features.share.api.ShareService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.SdkMetadata
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -34,6 +35,7 @@ class RootPresenter @Inject constructor(
|
||||
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
|
||||
private val appErrorStateService: AppErrorStateService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val shareService: ShareService,
|
||||
private val sdkMetadata: SdkMetadata,
|
||||
) : Presenter<RootState> {
|
||||
@Composable
|
||||
@@ -52,6 +54,10 @@ class RootPresenter @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
shareService.observeFeatureFlag(this)
|
||||
}
|
||||
|
||||
return RootState(
|
||||
rageshakeDetectionState = rageshakeDetectionState,
|
||||
crashDetectionState = crashDetectionState,
|
||||
|
||||
@@ -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<CoroutineScope, Unit> { _ -> }
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
1
changelog.d/1980.feature
Normal file
1
changelog.d/1980.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add support for incoming share (text or files) from other apps
|
||||
@@ -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)
|
||||
|
||||
29
features/share/api/build.gradle.kts
Normal file
29
features/share/api/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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<RoomId>)
|
||||
}
|
||||
|
||||
interface NodeBuilder {
|
||||
fun params(params: Params): NodeBuilder
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
70
features/share/impl/build.gradle.kts
Normal file
70
features/share/impl/build.gradle.kts
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.anvil)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.share.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(projects.anvilannotations)
|
||||
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.roomselect.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
api(libs.statemachine)
|
||||
api(projects.features.share.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
@@ -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<Plugin>()
|
||||
|
||||
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<ShareNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.share.impl
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.share.api.ShareService
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultShareService @Inject constructor(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ShareService {
|
||||
override fun observeFeatureFlag(coroutineScope: CoroutineScope) {
|
||||
val shareActivityComponent = getShareActivityComponent()
|
||||
?: return Unit.also {
|
||||
Timber.w("ShareActivity not found")
|
||||
}
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.IncomingShare)
|
||||
.onEach { enabled ->
|
||||
shareActivityComponent.enableOrDisable(enabled)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private fun getShareActivityComponent(): ComponentName? {
|
||||
return context.packageManager
|
||||
.getPackageInfo(
|
||||
context.packageName,
|
||||
PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS
|
||||
)
|
||||
.activities
|
||||
.firstOrNull { it.name.endsWith(".ShareActivity") }
|
||||
?.let { shareActivityInfo ->
|
||||
ComponentName(
|
||||
shareActivityInfo.packageName,
|
||||
shareActivityInfo.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ComponentName.enableOrDisable(enabled: Boolean) {
|
||||
val state = if (enabled) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
}
|
||||
try {
|
||||
context.packageManager.setComponentEnabledSetting(
|
||||
this,
|
||||
state,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to enable or disable the component")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.share.impl
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.IntentCompat
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeApplication
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeFile
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeText
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
interface ShareIntentHandler {
|
||||
data class UriToShare(
|
||||
val uri: Uri,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* This methods aims to handle incoming share intents.
|
||||
*
|
||||
* @return true if it can handle the intent data, false otherwise
|
||||
*/
|
||||
suspend fun handleIncomingShareIntent(
|
||||
intent: Intent,
|
||||
onUris: suspend (List<UriToShare>) -> Boolean,
|
||||
onPlainText: suspend (String) -> Boolean,
|
||||
): Boolean
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultShareIntentHandler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ShareIntentHandler {
|
||||
override suspend fun handleIncomingShareIntent(
|
||||
intent: Intent,
|
||||
onUris: suspend (List<ShareIntentHandler.UriToShare>) -> Boolean,
|
||||
onPlainText: suspend (String) -> Boolean,
|
||||
): Boolean {
|
||||
val type = intent.resolveType(context) ?: return false
|
||||
return when {
|
||||
type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText)
|
||||
type.isMimeTypeImage() ||
|
||||
type.isMimeTypeVideo() ||
|
||||
type.isMimeTypeAudio() ||
|
||||
type.isMimeTypeApplication() ||
|
||||
type.isMimeTypeFile() ||
|
||||
type.isMimeTypeText() ||
|
||||
type.isMimeTypeAny() -> {
|
||||
val uris = getIncomingUris(intent, type)
|
||||
val result = onUris(uris)
|
||||
revokeUriPermissions(uris.map { it.uri })
|
||||
result
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean {
|
||||
val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
|
||||
return if (content?.isNotEmpty() == true) {
|
||||
onPlainText(content)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this function to retrieve files which are shared from another application or internally
|
||||
* by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions.
|
||||
*/
|
||||
private fun getIncomingUris(intent: Intent, type: String): List<ShareIntentHandler.UriToShare> {
|
||||
val uriList = mutableListOf<Uri>()
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.let { uriList.add(it) }
|
||||
} else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.let { uriList.addAll(it) }
|
||||
}
|
||||
val resInfoList: List<ResolveInfo> = context.packageManager.queryIntentActivitiesCompat(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
uriList.forEach { uri ->
|
||||
resInfoList.forEach resolve@{ resolveInfo ->
|
||||
val packageName: String = resolveInfo.activityInfo.packageName
|
||||
// Replace implicit intent by an explicit to fix crash on some devices like Xiaomi.
|
||||
// see https://juejin.cn/post/7031736325422186510
|
||||
try {
|
||||
context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Unable to grant Uri permission")
|
||||
return@resolve
|
||||
}
|
||||
intent.action = null
|
||||
intent.component = ComponentName(packageName, resolveInfo.activityInfo.name)
|
||||
}
|
||||
}
|
||||
return uriList.map { uri ->
|
||||
ShareIntentHandler.UriToShare(
|
||||
uri = uri,
|
||||
mimeType = type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun revokeUriPermissions(uris: List<Uri>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.share.impl
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.share.api.ShareEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
|
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class ShareNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: SharePresenter.Factory,
|
||||
private val roomSelectEntryPoint: RoomSelectEntryPoint,
|
||||
) : ParentNode<ShareNode.NavTarget>(
|
||||
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<Inputs>()
|
||||
private val presenter = presenterFactory.create(inputs.intent)
|
||||
private val callbacks = plugins.filterIsInstance<ShareEntryPoint.Callback>()
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
val callback = object : RoomSelectEntryPoint.Callback {
|
||||
override fun onRoomSelected(roomIds: List<RoomId>) {
|
||||
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<RoomId>) {
|
||||
callbacks.forEach { it.onDone(roomIds) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.share.impl
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SharePresenter @AssistedInject constructor(
|
||||
@Assisted private val intent: Intent,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val shareIntentHandler: ShareIntentHandler,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
) : Presenter<ShareState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(intent: Intent): SharePresenter
|
||||
}
|
||||
|
||||
private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
|
||||
|
||||
fun onRoomSelected(roomIds: List<RoomId>) {
|
||||
appCoroutineScope.share(intent, roomIds)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): ShareState {
|
||||
fun handleEvents(event: ShareEvents) {
|
||||
when (event) {
|
||||
ShareEvents.ClearError -> shareActionState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return ShareState(
|
||||
shareAction = shareActionState.value,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.share(
|
||||
intent: Intent,
|
||||
roomIds: List<RoomId>,
|
||||
) = launch {
|
||||
suspend {
|
||||
val result = shareIntentHandler.handleIncomingShareIntent(
|
||||
intent,
|
||||
onUris = { filesToShare ->
|
||||
if (filesToShare.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
roomIds
|
||||
.map { roomId ->
|
||||
val room = matrixClient.getRoom(roomId) ?: return@map false
|
||||
val mediaSender = MediaSender(preProcessor = mediaPreProcessor, room = room)
|
||||
filesToShare
|
||||
.map { fileToShare ->
|
||||
mediaSender.sendMedia(
|
||||
uri = fileToShare.uri,
|
||||
mimeType = fileToShare.mimeType,
|
||||
compressIfPossible = true,
|
||||
).isSuccess
|
||||
}
|
||||
.all { it }
|
||||
}
|
||||
.all { it }
|
||||
}
|
||||
},
|
||||
onPlainText = { text ->
|
||||
roomIds
|
||||
.map { roomId ->
|
||||
matrixClient.getRoom(roomId)?.sendMessage(
|
||||
body = text,
|
||||
htmlBody = null,
|
||||
mentions = emptyList(),
|
||||
)?.isSuccess.orFalse()
|
||||
}
|
||||
.all { it }
|
||||
}
|
||||
)
|
||||
if (!result) {
|
||||
error("Failed to handle incoming share intent")
|
||||
}
|
||||
roomIds
|
||||
}.runCatchingUpdatingState(shareActionState)
|
||||
}
|
||||
}
|
||||
@@ -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<List<RoomId>>,
|
||||
val eventSink: (ShareEvents) -> Unit
|
||||
)
|
||||
@@ -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<ShareState> {
|
||||
override val values: Sequence<ShareState>
|
||||
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<List<RoomId>> = AsyncAction.Uninitialized,
|
||||
eventSink: (ShareEvents) -> Unit = {}
|
||||
) = ShareState(
|
||||
shareAction = shareAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
@@ -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<RoomId>) -> 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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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<ShareIntentHandler.UriToShare>) -> Boolean,
|
||||
suspend (String) -> Boolean,
|
||||
) -> Boolean = { _, _, _ -> false },
|
||||
) : ShareIntentHandler {
|
||||
override suspend fun handleIncomingShareIntent(
|
||||
intent: Intent,
|
||||
onUris: suspend (List<ShareIntentHandler.UriToShare>) -> Boolean,
|
||||
onPlainText: suspend (String) -> Boolean,
|
||||
): Boolean {
|
||||
return onIncomingShareIntent(intent, onUris, onPlainText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.share.impl
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SharePresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createSharePresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on room selected error then clear error`() = runTest {
|
||||
val presenter = createSharePresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
val failure = awaitItem()
|
||||
assertThat(failure.shareAction.isFailure()).isTrue()
|
||||
failure.eventSink.invoke(ShareEvents.ClearError)
|
||||
assertThat(awaitItem().shareAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on room selected ok`() = runTest {
|
||||
val presenter = createSharePresenter(
|
||||
shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true }
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
val success = awaitItem()
|
||||
assertThat(success.shareAction.isSuccess()).isTrue()
|
||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send text ok`() = runTest {
|
||||
val matrixRoom = FakeMatrixRoom()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, matrixRoom)
|
||||
}
|
||||
val presenter = createSharePresenter(
|
||||
matrixClient = matrixClient,
|
||||
shareIntentHandler = FakeShareIntentHandler { _, _, onText ->
|
||||
onText(A_MESSAGE)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
val success = awaitItem()
|
||||
assertThat(success.shareAction.isSuccess()).isTrue()
|
||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send media ok`() = runTest {
|
||||
val matrixRoom = FakeMatrixRoom()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, matrixRoom)
|
||||
}
|
||||
val presenter = createSharePresenter(
|
||||
matrixClient = matrixClient,
|
||||
shareIntentHandler = FakeShareIntentHandler { _, onFile, _ ->
|
||||
onFile(
|
||||
listOf(
|
||||
ShareIntentHandler.UriToShare(
|
||||
uri = Uri.parse("content://image.jpg"),
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
val success = awaitItem()
|
||||
assertThat(success.shareAction.isSuccess()).isTrue()
|
||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createSharePresenter(
|
||||
intent: Intent = Intent(),
|
||||
shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor()
|
||||
): SharePresenter {
|
||||
return SharePresenter(
|
||||
intent = intent,
|
||||
appCoroutineScope = this,
|
||||
shareIntentHandler = shareIntentHandler,
|
||||
matrixClient = matrixClient,
|
||||
mediaPreProcessor = mediaPreProcessor
|
||||
)
|
||||
}
|
||||
}
|
||||
28
features/share/test/build.gradle.kts
Normal file
28
features/share/test/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,22 @@
|
||||
|
||||
package io.element.android.libraries.androidutils.compat
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.os.Build
|
||||
|
||||
fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List<ResolveInfo> {
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> queryIntentActivities(
|
||||
data,
|
||||
PackageManager.ResolveInfoFlags.of(flags.toLong())
|
||||
)
|
||||
else -> @Suppress("DEPRECATION") queryIntentActivities(data, flags)
|
||||
}
|
||||
}
|
||||
|
||||
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo {
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApplicationInfo(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,4 +18,5 @@ package io.element.android.libraries.roomselect.api
|
||||
|
||||
enum class RoomSelectMode {
|
||||
Forward,
|
||||
Share,
|
||||
}
|
||||
|
||||
@@ -31,29 +31,33 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
|
||||
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<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.Initial(),
|
||||
query: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
selectedRooms: ImmutableList<RoomSummaryDetails> = 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"),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
<string name="common_search_results">"Search results"</string>
|
||||
<string name="common_security">"Security"</string>
|
||||
<string name="common_seen_by">"Seen by"</string>
|
||||
<string name="common_send_to">"Send to"</string>
|
||||
<string name="common_sending">"Sending…"</string>
|
||||
<string name="common_sending_failed">"Sending failed"</string>
|
||||
<string name="common_sent">"Sent"</string>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user