Merge pull request #321 from vector-im/feature/bma/push3
Handle navigation on notification click
This commit is contained in:
commit
c84f6ea74f
20 changed files with 430 additions and 33 deletions
|
|
@ -206,6 +206,7 @@ dependencies {
|
||||||
allLibrariesImpl()
|
allLibrariesImpl()
|
||||||
allServicesImpl()
|
allServicesImpl()
|
||||||
allFeaturesImpl(rootDir)
|
allFeaturesImpl(rootDir)
|
||||||
|
implementation(projects.libraries.deeplink)
|
||||||
implementation(projects.tests.uitests)
|
implementation(projects.tests.uitests)
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
implementation(projects.appnav)
|
implementation(projects.appnav)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Handle deep-link for notification, uncomment to be able to test deeplink with ./tools/adb/deeplink.sh -->
|
||||||
|
<!--intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data
|
||||||
|
android:host="open"
|
||||||
|
android:scheme="elementx" />
|
||||||
|
</intent-filter-->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import com.bumble.appyx.core.integration.NodeHost
|
import com.bumble.appyx.core.integration.NodeHost
|
||||||
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
||||||
|
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
import io.element.android.x.di.AppBindings
|
import io.element.android.x.di.AppBindings
|
||||||
|
|
@ -35,6 +36,8 @@ import timber.log.Timber
|
||||||
|
|
||||||
class MainActivity : NodeComponentActivity() {
|
class MainActivity : NodeComponentActivity() {
|
||||||
|
|
||||||
|
lateinit var mainNode: MainNode
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -44,10 +47,23 @@ class MainActivity : NodeComponentActivity() {
|
||||||
setContent {
|
setContent {
|
||||||
ElementTheme {
|
ElementTheme {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||||
MainNode(it, appBindings.mainDaggerComponentOwner())
|
MainNode(
|
||||||
|
it,
|
||||||
|
appBindings.mainDaggerComponentOwner(),
|
||||||
|
plugins = listOf(
|
||||||
|
object : NodeReadyObserver<MainNode> {
|
||||||
|
override fun init(node: MainNode) {
|
||||||
|
mainNode = node
|
||||||
|
mainNode.handleIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +79,8 @@ class MainActivity : NodeComponentActivity() {
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent?) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
Timber.w("onNewIntent")
|
Timber.w("onNewIntent")
|
||||||
|
intent ?: return
|
||||||
|
mainNode.handleIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,17 @@
|
||||||
|
|
||||||
package io.element.android.x
|
package io.element.android.x
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.bumble.appyx.core.composable.Children
|
import com.bumble.appyx.core.composable.Children
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.ParentNode
|
import com.bumble.appyx.core.node.ParentNode
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import io.element.android.appnav.LoggedInFlowNode
|
import io.element.android.appnav.LoggedInFlowNode
|
||||||
import io.element.android.appnav.RoomFlowNode
|
import io.element.android.appnav.RoomFlowNode
|
||||||
import io.element.android.appnav.RootFlowNode
|
import io.element.android.appnav.RootFlowNode
|
||||||
|
|
@ -35,11 +38,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.x.di.MainDaggerComponentsOwner
|
import io.element.android.x.di.MainDaggerComponentsOwner
|
||||||
import io.element.android.x.di.RoomComponent
|
import io.element.android.x.di.RoomComponent
|
||||||
import io.element.android.x.di.SessionComponent
|
import io.element.android.x.di.SessionComponent
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
class MainNode(
|
class MainNode(
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
private val mainDaggerComponentOwner: MainDaggerComponentsOwner,
|
private val mainDaggerComponentOwner: MainDaggerComponentsOwner,
|
||||||
|
plugins: List<Plugin>,
|
||||||
) :
|
) :
|
||||||
ParentNode<MainNode.RootNavTarget>(
|
ParentNode<MainNode.RootNavTarget>(
|
||||||
navModel = PermanentNavModel(
|
navModel = PermanentNavModel(
|
||||||
|
|
@ -47,6 +52,7 @@ class MainNode(
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
|
plugins = plugins,
|
||||||
),
|
),
|
||||||
DaggerComponentOwner by mainDaggerComponentOwner {
|
DaggerComponentOwner by mainDaggerComponentOwner {
|
||||||
|
|
||||||
|
|
@ -73,7 +79,13 @@ class MainNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
|
||||||
return createNode<RootFlowNode>(buildContext, plugins = listOf(loggedInFlowNodeCallback, roomFlowNodeCallback))
|
return createNode<RootFlowNode>(
|
||||||
|
context = buildContext,
|
||||||
|
plugins = listOf(
|
||||||
|
loggedInFlowNodeCallback,
|
||||||
|
roomFlowNodeCallback,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -81,6 +93,12 @@ class MainNode(
|
||||||
Children(navModel = navModel)
|
Children(navModel = navModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleIntent(intent: Intent) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
waitForChildAttached<RootFlowNode>().handleIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object RootNavTarget : Parcelable
|
object RootNavTarget : Parcelable
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ package io.element.android.x.intent
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.deeplink.DeepLinkCreator
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -28,17 +30,19 @@ import io.element.android.libraries.push.impl.intent.IntentProvider
|
||||||
import io.element.android.x.MainActivity
|
import io.element.android.x.MainActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
// TODO EAx change to deep-link.
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class IntentProviderImpl @Inject constructor(
|
class IntentProviderImpl @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
private val deepLinkCreator: DeepLinkCreator,
|
||||||
) : IntentProvider {
|
) : IntentProvider {
|
||||||
override fun getMainIntent(): Intent {
|
override fun getViewIntent(
|
||||||
return Intent(context, MainActivity::class.java)
|
sessionId: SessionId,
|
||||||
}
|
roomId: RoomId?,
|
||||||
|
threadId: ThreadId?,
|
||||||
override fun getIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): Intent {
|
): Intent {
|
||||||
// TODO Handle deeplink or pass parameters
|
return Intent(context, MainActivity::class.java).apply {
|
||||||
return Intent(context, MainActivity::class.java)
|
action = Intent.ACTION_VIEW
|
||||||
|
data = deepLinkCreator.create(sessionId, roomId, threadId).toUri()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.deeplink)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.push.api)
|
implementation(projects.libraries.push.api)
|
||||||
implementation(projects.libraries.pushproviders.api)
|
implementation(projects.libraries.pushproviders.api)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import com.bumble.appyx.core.plugin.plugins
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
|
@ -56,10 +57,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
|
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class LoggedInFlowNode @AssistedInject constructor(
|
class LoggedInFlowNode @AssistedInject constructor(
|
||||||
|
|
@ -217,6 +215,19 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun attachRoot(): Node {
|
||||||
|
return attachChild {
|
||||||
|
backstack.singleTop(NavTarget.RoomList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun attachRoom(roomId: RoomId): RoomFlowNode {
|
||||||
|
return attachChild {
|
||||||
|
backstack.singleTop(NavTarget.RoomList)
|
||||||
|
backstack.push(NavTarget.Room(roomId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ package io.element.android.appnav
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.composable.Children
|
import com.bumble.appyx.core.composable.Children
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.appnav
|
package io.element.android.appnav
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -45,6 +46,8 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.deeplink.DeeplinkData
|
||||||
|
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
|
@ -65,6 +68,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
private val matrixClientsHolder: MatrixClientsHolder,
|
private val matrixClientsHolder: MatrixClientsHolder,
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||||
|
private val deeplinkParser: DeeplinkParser,
|
||||||
) :
|
) :
|
||||||
BackstackNode<RootFlowNode.NavTarget>(
|
BackstackNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -207,4 +211,30 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun handleIntent(intent: Intent) {
|
||||||
|
deeplinkParser.getFromIntent(intent)
|
||||||
|
?.let { navigateTo(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
||||||
|
Timber.d("Navigating to $deeplinkData")
|
||||||
|
attachSession(deeplinkData.sessionId)
|
||||||
|
.apply {
|
||||||
|
val roomId = deeplinkData.roomId
|
||||||
|
if (roomId == null) {
|
||||||
|
// In case room is not provided, ensure the app navigate back to the room list
|
||||||
|
attachRoot()
|
||||||
|
} else {
|
||||||
|
attachRoom(roomId)
|
||||||
|
// TODO .attachThread(deeplinkData.threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
||||||
|
return attachChild {
|
||||||
|
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
libraries/deeplink/build.gradle.kts
Normal file
43
libraries/deeplink/build.gradle.kts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
|
||||||
|
@Suppress("DSL_SCOPE_VIOLATION")
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.deeplink"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.di)
|
||||||
|
implementation(libs.dagger)
|
||||||
|
implementation(libs.androidx.corektx)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.tests.testutils)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.deeplink
|
||||||
|
|
||||||
|
internal const val SCHEME = "elementx"
|
||||||
|
internal const val HOST = "open"
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.deeplink
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DeepLinkCreator @Inject constructor() {
|
||||||
|
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
|
||||||
|
return buildString {
|
||||||
|
append("$SCHEME://$HOST/")
|
||||||
|
append(sessionId.value)
|
||||||
|
if (roomId != null) {
|
||||||
|
append("/")
|
||||||
|
append(roomId.value)
|
||||||
|
if (threadId != null) {
|
||||||
|
append("/")
|
||||||
|
append(threadId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.deeplink
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
|
||||||
|
data class DeeplinkData(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val roomId: RoomId? = null,
|
||||||
|
val threadId: ThreadId? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.deeplink
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import io.element.android.libraries.matrix.api.core.asRoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.asSessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.asThreadId
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DeeplinkParser @Inject constructor() {
|
||||||
|
fun getFromIntent(intent: Intent): DeeplinkData? {
|
||||||
|
return intent
|
||||||
|
.takeIf { it.action == Intent.ACTION_VIEW }
|
||||||
|
?.data
|
||||||
|
?.toDeeplinkData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.toDeeplinkData(): DeeplinkData? {
|
||||||
|
if (scheme != SCHEME) return null
|
||||||
|
if (host != HOST) return null
|
||||||
|
val pathBits = path.orEmpty().split("/").drop(1)
|
||||||
|
val sessionId = pathBits.elementAtOrNull(0)?.asSessionId() ?: return null
|
||||||
|
val roomId = pathBits.elementAtOrNull(1)?.asRoomId()
|
||||||
|
val threadId = pathBits.elementAtOrNull(2)?.asThreadId()
|
||||||
|
return DeeplinkData(
|
||||||
|
sessionId = sessionId,
|
||||||
|
roomId = roomId,
|
||||||
|
threadId = threadId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.deeplink
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DeepLinkCreatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun create() {
|
||||||
|
val sut = DeepLinkCreator()
|
||||||
|
assertThat(sut.create(A_SESSION_ID, null, null))
|
||||||
|
.isEqualTo("elementx://open/@alice:server.org")
|
||||||
|
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null))
|
||||||
|
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain")
|
||||||
|
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
|
||||||
|
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.deeplink
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||||
|
import io.element.android.tests.testutils.assertNullOrThrow
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class DeeplinkParserTest {
|
||||||
|
companion object {
|
||||||
|
const val A_URI =
|
||||||
|
"elementx://open/@alice:server.org"
|
||||||
|
const val A_URI_WITH_ROOM =
|
||||||
|
"elementx://open/@alice:server.org/!aRoomId:domain"
|
||||||
|
const val A_URI_WITH_ROOM_WITH_THREAD =
|
||||||
|
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sut = DeeplinkParser()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nominal cases`() {
|
||||||
|
assertThat(sut.getFromIntent(createIntent(A_URI)))
|
||||||
|
.isEqualTo(DeeplinkData(A_SESSION_ID, null, null))
|
||||||
|
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM)))
|
||||||
|
.isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, null))
|
||||||
|
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD)))
|
||||||
|
.isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `error cases`() {
|
||||||
|
val sut = DeeplinkParser()
|
||||||
|
// Bad scheme
|
||||||
|
assertThat(sut.getFromIntent(createIntent("x://open/@alice:server.org"))).isNull()
|
||||||
|
// Bad host
|
||||||
|
assertThat(sut.getFromIntent(createIntent("elementx://close/@alice:server.org"))).isNull()
|
||||||
|
// No session Id
|
||||||
|
assertThat(sut.getFromIntent(createIntent("elementx://open"))).isNull()
|
||||||
|
// Invalid sessionId
|
||||||
|
assertNullOrThrow {
|
||||||
|
sut.getFromIntent(createIntent("elementx://open/alice:server.org"))
|
||||||
|
}
|
||||||
|
// Empty sessionId
|
||||||
|
assertNullOrThrow {
|
||||||
|
sut.getFromIntent(createIntent("elementx://open//"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createIntent(uri: String): Intent {
|
||||||
|
return Intent().apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = uri.toUri()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,9 +25,7 @@ interface IntentProvider {
|
||||||
/**
|
/**
|
||||||
* Provide an intent to start the application.
|
* Provide an intent to start the application.
|
||||||
*/
|
*/
|
||||||
fun getMainIntent(): Intent
|
fun getViewIntent(
|
||||||
|
|
||||||
fun getIntent(
|
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
roomId: RoomId?,
|
roomId: RoomId?,
|
||||||
threadId: ThreadId?,
|
threadId: ThreadId?,
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ data class NotificationActionIds @Inject constructor(
|
||||||
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
|
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
|
||||||
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
|
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
|
||||||
val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
|
val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
|
||||||
val tapToView = "${buildMeta.applicationId}.NotificationActions.TAP_TO_VIEW_ACTION"
|
|
||||||
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
|
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
|
||||||
val push = "${buildMeta.applicationId}.PUSH"
|
val push = "${buildMeta.applicationId}.PUSH"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -482,15 +482,11 @@ class NotificationUtils @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildOpenRoomIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
|
private fun buildOpenRoomIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
|
||||||
val roomIntent = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = null)
|
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = null)
|
||||||
roomIntent.action = actionIds.tapToView
|
|
||||||
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
|
|
||||||
roomIntent.data = createIgnoredUri("openRoom?$sessionId&$roomId")
|
|
||||||
|
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
clock.epochMillis().toInt(),
|
clock.epochMillis().toInt(),
|
||||||
roomIntent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -498,22 +494,17 @@ class NotificationUtils @Inject constructor(
|
||||||
private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
|
private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
|
||||||
val sessionId = roomInfo.sessionId
|
val sessionId = roomInfo.sessionId
|
||||||
val roomId = roomInfo.roomId
|
val roomId = roomInfo.roomId
|
||||||
val threadIntentTap = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
|
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
|
||||||
threadIntentTap.action = actionIds.tapToView
|
|
||||||
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
|
|
||||||
threadIntentTap.data = createIgnoredUri("openThread?$sessionId&$roomId&$threadId")
|
|
||||||
|
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
clock.epochMillis().toInt(),
|
clock.epochMillis().toInt(),
|
||||||
threadIntentTap,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildOpenHomePendingIntentForSummary(sessionId: SessionId): PendingIntent {
|
private fun buildOpenHomePendingIntentForSummary(sessionId: SessionId): PendingIntent {
|
||||||
val intent = intentProvider.getIntent(sessionId = sessionId, roomId = null, threadId = null)
|
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = null, threadId = null)
|
||||||
intent.data = createIgnoredUri("tapSummary?$sessionId")
|
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
clock.epochMillis().toInt(),
|
clock.epochMillis().toInt(),
|
||||||
|
|
|
||||||
28
tools/adb/deeplink.sh
Executable file
28
tools/adb/deeplink.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#! /bin/bash
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
|
||||||
|
# Format is:
|
||||||
|
# elementx://open/{sessionId} to open a session
|
||||||
|
# elementx://open/{sessionId}/{roomId} to open a room
|
||||||
|
# elementx://open/{sessionId}/{roomId}/{eventId} to open a thread
|
||||||
|
|
||||||
|
# Open a session
|
||||||
|
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org
|
||||||
|
# Open a room
|
||||||
|
adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org
|
||||||
|
# Open a thread
|
||||||
|
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org/\\\$threadId
|
||||||
Loading…
Add table
Add a link
Reference in a new issue