android-jetpack-composejetpack-compose-navigation

How to navigate from notification to specific screen in an android jetpack compose single activity application?


I want to navigate from a notification action button to a specific screen in my single activity application in compose. Based on this documentation I decided to use deep-link navigation. The problem is that when I click on the notification action button, it restarts my activity before navigating to the expected screen. I don't want my activity to restart if it's opened in the background.

This is how I did it:

Manifest.xml

Here is what I specified in the application manifest:

<activity
    android:name=".ui.AppActivity"
    android:launchMode="standard"
    android:exported="true">
    <intent-filter>
         <action android:name="android.intent.action.MAIN" />
         <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
         <action android:name="android.intent.action.VIEW" />
         <category android:name="android.intent.category.DEFAULT" />
         <category android:name="android.intent.category.BROWSABLE" />
         <data android:scheme="myApp" android:host="screenRoute" />
    </intent-filter>
</activity>

Root nav graph

Here is the deep link declaration in my root navigation graph:

composable(
   route = "screenRoute",
   deepLinks = listOf(navDeepLink { uriPattern = "myApp://screenRoute" })
) {
   ComposableScreen()
}

Pending intent

Here is the pending intent I use for the notification action button:

val intent = Intent().apply {
    action = Intent.ACTION_VIEW
    data = "myApp://screenRoute".toUri()
}

val deepLinkPendingIntent = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(intent)
    getPendingIntent(1234, FLAG_UPDATE_CURRENT)
}

I thought that I did something wrong here because I didn't find anything about this restart. So I downloaded the official compose navigation codelabs that uses deep links (as it's also a single app activity) and it does the same when using deep links from intents, the activity is restarted.

So my questions are:

  1. Is it possible to achieve deep link navigation from notification in single activity app without restarting it ?
  2. If not, what's the way of achieving this workflow (opening a specific composable from a notification with no restart) ? Should I send a broadcast from the notification action button and use deep link navigation from within my app ?
  3. Is the activity restarting from deep links because it's the main activity (launcher) ?

Thanks


Solution

  • Problems

    I see two problems here :

    Possible solution - Handling deeplink yourself

    I think that there is multiple solutions for your problem. However, here is one possible solution

    Update the launch mode

    To ensure that you will always have one instance of your app, you need to set the launchMode to singleTask

    <activity
        android:name=".ui.AppActivity"
        android:launchMode="singleTask"
        android:exported="true">
        <intent-filter>
             <action android:name="android.intent.action.MAIN" />
             <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
        <intent-filter>
             <action android:name="android.intent.action.VIEW" />
             <category android:name="android.intent.category.DEFAULT" />
             <category android:name="android.intent.category.BROWSABLE" />
             <data android:scheme="myApp" android:host="screenRoute" />
        </intent-filter>
    </activity>
    

    Cold and host start

    Cold start

    When your app is not starting yet and you open a deeplink, the app will start and your activity will be created. So, in the onStart method, you will be able to handle the deeplink :

    override fun onStart() {
            super.onStart()
            intent?.data?.let { /* handle deeplink */ }
            // consume the deeplink
            intent = null
    
        }
    
    Hot start

    When your app is already running, clicking on a deeplink will bring the app to front and it will trigger an observer with the intent.

    setContent {
                DisposableEffect(Unit) {
                    val listener = Consumer<Intent> { intent ->
                        // Handle deeplink
    
                    }
                    addOnNewIntentListener(listener)
                    onDispose { removeOnNewIntentListener(listener) }
                }
            }
    

    Handling deeplink with VM

    In cold start, you are not in a composable scope. To fix this issue, you can use your VM as an event emitter for your view.

    class MyViewModel : ViewModel() {
        val event = MutableStateFlow<Event>(Event.None)
    
        fun handleDeeplink(uri: Uri) {
            event.update { Event.NavigateWithDeeplink(uri) }
        }
    
        fun consumeEvent() {
            event.update { Event.None }
        }
    }
    sealed interface Event {
        data class NavigateWithDeeplink(val deeplink: Uri) : Event
        object None : Event
    }
    

    In the cold start case, call the handleDeeplink(uri) method

    
    override fun onStart() {
            super.onStart()
            // To handle a cold deeplink intent we need to keep it and replace it with null
            intent?.data?.let { myViewModel.handleDeeplink(it) }
            intent = null
        }
    

    In the hot start case, call it too

    DisposableEffect(Unit) {
                        val listener = Consumer<Intent> { intent ->
                            intent.data?.let {
                               myViewModel.handleDeeplink(it)
                            }
                        }
                        addOnNewIntentListener(listener)
                        onDispose { removeOnNewIntentListener(listener) }
                    }
    

    Now, in your main composable, collect the event as state and navigate to deeplink when you receive the event. Don't forget to consume it because we are using stateFlow here.

     val event by myViewModel.event.collectAsState()
    
                    LaunchedEffect(event) {
                        when (val currentEvent = event) {
                            is Event.NavigateWithDeeplink -> navController.navigate(currentEvent.deeplink)
                            Event.None -> Unit
                        }
    
                        myViewModel.consumeEvent()
                    }
    
    

    Creating the PendingIntent

    Like I said, Using TaskStackBuilder will recreate an activity. Instead of using it to create a pending intent, you can create it yourself

    val routeIntent = Intent(
                Intent.ACTION_VIEW,
                MyUri
            ).apply {
                flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
            }
    
            val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
    
            val pending = PendingIntent.getActivity(
                appContext,
                0,
                routeIntent,
                flags
            )