Extend dynamic shortcuts to Google Assistant with App Actions

1. Overview

In the previous codelab, you used static shortcuts to implement commonly used built-in intents (BII) in a sample app. Android developers use App Actions to extend app functionality to Google Assistant.

Static shortcuts are bundled with an app and can only be updated by releasing new versions of the app. Enabling voice functionality for dynamic elements in an app, like user-generated content, is achieved using dynamic shortcuts. Apps push dynamic shortcuts after users perform relevant actions, like creating a new note in a task tracking app. With App Actions, you enable these shortcuts for voice by binding them to a BII, enabling users to access their content from Assistant by saying things like, "Hey Google, open my grocery list on ExampleApp."

Three progressive screens showing Google Assistant launching a dynamic shortcut.

Figure 1. Three progressive screens showing a user-created task, and Google Assistant launching a dynamic shortcut to that task item.

What you'll build

In this codelab, you'll enable dynamic shortcuts for voice in a sample to-do list Android app, enabling users to ask Assistant to open the task list items they create in the app. You accomplish this using using Android architecture patterns, specifically the repository, service locator and ViewModel patterns.

Prerequisites

This codelab builds on the App Actions concepts covered in the previous codelab, particularly BIIs and static shortcuts. If you are new to App Actions, we recommend completing that codelab before continuing.

Additionally, ensure that your development environment has the following configuration before proceeding:

  • A terminal to run shell commands with git installed.
  • The latest stable release of Android Studio.
  • A physical or virtual Android device with Internet access.
  • A Google account that is signed into Android Studio, the Google app, and the Google Assistant app.

2. Understand how it works

Enabling a dynamic shortcut for voice access with involves the following steps:

Binding shortcuts

For a dynamic shortcut to be accessible from Assistant, it needs to be bound to a relevant BII. When a BII with a shortcut is triggered, Assistant matches parameters in the user request to keywords defined in the bound shortcut. For example:

  • A shortcut bound to the GET_THING BII could allow users to request specific in-app content, directly from Assistant. * "Hey Google, open my grocery list on ExampleApp."
  • A shortcut bound to the ORDER_MENU_ITEM BII could allow users to replay previous orders. * "Hey Google, order my usual from ExampleApp."

Refer to the Built-in intents reference for a full categorized list of BIIs.

Providing shortcuts to Assistant

After binding your shortcuts to a BII, the next step is to enable Assistant to ingest these shortcuts by adding the Google Shortcuts Integration library to your project. With this library in place, Assistant will be aware of each shortcut pushed by your app, enabling users to launch those shortcuts by using the shortcut's trigger phrase in Assistant.

3. Prepare your development environment

This codelab uses a sample to-do list app built for Android. With this app, users can add items to lists, search for task list items by category, and filter tasks by completion status. Download and prepare the sample app by completing this section.

Download your base files

Run the following command to clone the sample app's GitHub repository:

git clone https://github.com/actions-on-google/app-actions-dynamic-shortcuts.git

Once you've cloned the repository, follow these steps to open it in Android Studio:

  1. In the Welcome to Android Studio dialog, click Import project.
  2. Select the folder where you cloned the repository.

Alternatively, you can view a version of the sample app representing the completed codelab by cloning the codelab-complete branch of its Github repo:

git clone https://github.com/actions-on-google/app-actions-dynamic-shortcuts.git --branch codelab-complete

Update the Android application ID

Updating the app's application ID uniquely identifies the app on your test device and avoids a "Duplicate package name" error if the app is uploaded to the Play Console. To update the application ID, open app/build.gradle:

android {
...
  defaultConfig {
    applicationId "com.MYUNIQUENAME.android.fitactions"
    ...
  }
}

Replace "MYUNIQUENAME" in the applicationId field to something unique to you.

Add Shortcuts API dependencies

Add the following Jetpack libraries to the app/build.gradle resource file:

app/build.gradle

dependencies {
   ...
   // Shortcuts library
   implementation "androidx.core:core:1.6.0"
   implementation 'androidx.core:core-google-shortcuts:1.0.1'
   ...
}

Test the app on your device

Before making more changes to the app, it's helpful to get an idea of what the sample app can do. To run the app on your emulator, follow these steps:

  1. In Android Studio, select Run > Run app or click RunRun app icon in Android Studio in the toolbar.
  2. In the Select Deployment Target dialog, select a device and click OK. The recommended OS version is Android 10 (API level 30) or higher, although App Actions works on devices back to Android 5 (API level 21).
  3. Long-press on the Home button to set up Assistant and verify that it works. You will need to sign in to Assistant on your device, if you haven't already.

For more information on Android virtual devices, see Create and manage virtual devices.

Briefly explore the app to see what it can do. Tapping the Plus icon creates a new task item, and the menu items on the top right allow you to search for and filter task items by completion status.

4. Create a shortcut repository class

Several classes in our sample app will call the ShortcutManagerCompat API to push and manage dynamic shortcuts. To reduce code redundancy, you will implement a repository to enable your project classes to easily manage dynamic shortcuts.

The repository design pattern provides a clean API for managing shortcuts. The advantage to a repository is the details of the underlying API are uniformly abstracted behind a minimal API. Implement the repository by following these steps:

  1. Create a ShortcutsRepository class to abstract the ShortcutManagerCompat API.
  2. Add ShortcutsRepository methods to the app's service locator.
  3. Register the ShortcutRepository service in the main Application.

Create the repository

Create a new Kotlin class named ShortcutsRepository in the com.example.android.architecture.blueprints.todoapp.data.source package. You can find this package organized in the app/src/main/java folder. You will use this class to implement an interface providing a minimal set of methods covering our codelab use case.

Android Studio window displaying location of ShortcutsRepository class.

Figure 2. Android Studio Project Files window displaying the location of the ShortcutsRepository class.

Paste the following code into the new class:

package com.example.android.architecture.blueprints.todoapp.data.source

import android.content.Context
import android.content.Intent
import androidx.annotation.WorkerThread
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import com.example.android.architecture.blueprints.todoapp.data.Task
import com.example.android.architecture.blueprints.todoapp.tasks.TasksActivity

private const val GET_THING_KEY = "q"

/**
* ShortcutsRepository provides an interface for managing dynamic shortcuts.
*/
class ShortcutsRepository(val context: Context) {

   private val appContext = context.applicationContext

   /**
    * Pushes a dynamic shortcut. The task ID is used as the shortcut ID.
    * The task's title and description are used as shortcut's short and long labels.
    * The resulting shortcut corresponds to the GET_THING capability with task's
    * title used as BII's "name" argument.
    *
    * @param task Task object for which to create a shortcut.
    */
   @WorkerThread
   fun pushShortcut(task: Task) {
      // TODO
   }

   private fun createShortcutCompat(task: Task): ShortcutInfoCompat {
      //...
   }

   /**
    *  Updates a dynamic shortcut for the provided task. If the shortcut
    *  associated with this task doesn't exist, this method throws an error.
    *  This operation may take a few seconds to complete.
    *
    * @param tasks list of tasks to update.
    */
   @WorkerThread
   fun updateShortcuts(tasks: List<Task>) {
       //...
   }

   /**
    * Removes shortcuts if IDs are known.
    *
    * @param ids list of shortcut IDs
    */
   @WorkerThread
   fun removeShortcutsById(ids: List<String>) {
       //...
   }

   /**
    * Removes shortcuts associated with the tasks.
    *
    * @param tasks list of tasks to remove.
    */
   @WorkerThread
   fun removeShortcuts(tasks: List<Task>) {
       //...
   }
}

Next, update the pushShortcut method to call the ShortcutManagerCompat API. Update the ShortcutsRepository class with the following code:

ShortcutsRepository.kt

/**
* Pushes a dynamic shortcut for the task. The task's ID is used as a shortcut
* ID. The task's title and description are used as shortcut's short and long
* labels. The created shortcut corresponds to GET_THING capability with task's
* title used as BII's "name" argument.
*
* @param task Task object for which to create a shortcut.
*/


@WorkerThread
fun pushShortcut(task: Task) {
   ShortcutManagerCompat.pushDynamicShortcut(appContext, createShortcutCompat(task))
}

In the preceding code sample we passed appContext to the API. This is a class property holding an Application Context. It's important to use an Application Context (as opposed to an Activity Context) to avoid memory leaks, since the context might be retained longer than the host activity lifecycle.

Additionally, the API requires that we pass a ShortcutInfoCompat object for the Task object. In the preceding code sample we accomplish this by by calling the createShortcutCompat private method, which we will update to create and return a ShortcutInfoCompat object. To accomplish this, update the createShortcutCompat stub with the following code:

ShortcutsRepository.kt

private fun createShortcutCompat(task: Task): ShortcutInfoCompat {
   val intent = Intent(appContext, TasksActivity::class.java)
   intent.action = Intent.ACTION_VIEW
   // Filtering is set based on currentTitle.
   intent.putExtra(GET_THING_KEY, task.title)

   // A unique ID is required to avoid overwriting an existing shortcut.
   return ShortcutInfoCompat.Builder(appContext, task.id)
           .setShortLabel(task.title)
           .setLongLabel(task.title)
           // Call addCapabilityBinding() to link this shortcut to a BII. Enables user to invoke a shortcut using its title in Assistant.
           .addCapabilityBinding(
                   "actions.intent.GET_THING", "thing.name", listOf(task.title))
           .setIntent(intent)
           .setLongLived(false)
       .build()
}

The remaining function stubs in this class deal with updating and deleting dynamic shortcuts. Enable these functions by updating them with the following code:

ShortcutsRepository.kt

/**
* Updates a Dynamic Shortcut for the task. If the shortcut associated with this task
* doesn't exist, throws an error. This operation may take a few seconds to complete.
*
* @param tasks list of tasks to update.
*/
@WorkerThread
fun updateShortcuts(tasks: List<Task>) {
   val scs = tasks.map { createShortcutCompat(it) }
   ShortcutManagerCompat.updateShortcuts(appContext, scs)
}

/**
* Removes shortcuts if IDs are known.
* @param ids list of shortcut IDs
*/
@WorkerThread
fun removeShortcutsById(ids: List<String>) {
   ShortcutManagerCompat.removeDynamicShortcuts(appContext, ids)
}

/**
* Removes shortcuts associated with the tasks.
*
* @param tasks list of tasks to remove.
*/
@WorkerThread
fun removeShortcuts(tasks: List<Task>) {
   ShortcutManagerCompat.removeDynamicShortcuts (appContext,
           tasks.map { it.id })
}

Add class to service locator

With the ShortcutsRepository class created, the next step is to make instantiated objects of this class available to the rest of the app. This app manages class dependencies by implementing the service locator pattern. Open the service locator class using the class browser in Android Studio by going to Navigate > Class and typing "ServiceLocator". Click the resulting Kotlin file to open it in your IDE.

At the top of ServiceLocator.kt, paste the following code to import the ShortcutsRepository and SuppressLint packages:

ServiceLocator.kt

package com.example.android.architecture.blueprints.todoapp

// ...Other import statements
import com.example.android.architecture.blueprints.todoapp.data.source.ShortcutsRepository
import android.annotation.SuppressLint

Add the ShortcutRepository service members and methods by pasting the following code into the body of ServiceLocator.kt:

ServiceLocator.kt

object ServiceLocator {

   // ...
   // Only the code immediately below this comment needs to be copied and pasted
   // into the body of ServiceLocator.kt:

   @SuppressLint("StaticFieldLeak")
   @Volatile
   var shortcutsRepository: ShortcutsRepository? = null


   private fun createShortcutsRepository(context: Context): ShortcutsRepository {
       val newRepo = ShortcutsRepository(context.applicationContext)
       shortcutsRepository = newRepo
       return newRepo
   }

   fun provideShortcutsRepository(context: Context): ShortcutsRepository {
       synchronized(this) {
           return shortcutsRepository ?: shortcutsRepository ?: createShortcutsRepository(context)
       }
   }
 }

Register the shortcut service

The final step is to register your new ShortcutsRepository service with the Application. In Android Studio, open TodoApplication.kt and copy the following code near the top of the file:

TodoApplication.kt

package com.example.android.architecture.blueprints.todoapp
/// ... Other import statements

import com.example.android.architecture.blueprints.todoapp.data.source.ShortcutsRepository

Next, register the service by adding the following code to the body of the class:

TodoApplication.kt

//...
class TodoApplication : Application() {

   //...

   val shortcutsRepository: ShortcutsRepository
       get() = ServiceLocator.provideShortcutsRepository(this)

   //...
}

Build the app and make sure that it continues to run.

5. Push a new shortcut

With your shortcut service created, you're ready to start pushing shortcuts. Since users generate content (task items) in this app and expect to be able to return to them later, we will voice-enable access to this content by pushing a dynamic shortcut that is bound to the GET_THING BII each time a user creates a new task. This enables Assistant to launch users directly to their requested task item when they trigger the BII by asking things like, "Hey Google, open my grocery list on SampleApp."

You enable this functionality in the sample app by completing these steps:

  1. Importing the ShortcutsRepository service to the AddEditTaskViewModel class, responsible for managing task list objects.
  2. Pushing a dynamic shortcut when the user creates a new task.

Import ShortcutsRepository

We first need to make the ShortcutsRepository service available to AddEditTaskViewModel. To accomplish this, import the service to ViewModelFactory, the factory class the app uses to instantiate ViewModel objects, including AddEditTaskViewModel.

Open the class browser in Android Studio by going to Navigate > Class and typing "ViewModelFactory". Click the resulting Kotlin file to open it in your IDE.

At the top of ViewModelFactory.kt, paste the following code to import the ShortcutsRepository and SuppressLint packages:

ViewModelFactory.kt

package com.example.android.architecture.blueprints.todoapp

// ...Other import statements
import com.example.android.architecture.blueprints.todoapp.data.source.ShortcutsRepository

Next, replace the body of ViewModelFactory with the following code:

ViewModelFactory.kt

/**
 * Factory for all ViewModels.
 */
@Suppress("UNCHECKED_CAST")
class ViewModelFactory constructor(
    private val tasksRepository: TasksRepository,
    private val shortcutsRepository: ShortcutsRepository,
    owner: SavedStateRegistryOwner,
    defaultArgs: Bundle? = null
) : AbstractSavedStateViewModelFactory(owner, defaultArgs) {

    override fun <T : ViewModel> create(
        key: String,
        modelClass: Class<T>,
        handle: SavedStateHandle
    ) = with(modelClass) {
        when {
            isAssignableFrom(StatisticsViewModel::class.java) ->
                StatisticsViewModel(tasksRepository)
            isAssignableFrom(TaskDetailViewModel::class.java) ->
                TaskDetailViewModel(tasksRepository)
            isAssignableFrom(AddEditTaskViewModel::class.java) ->
                AddEditTaskViewModel(tasksRepository, shortcutsRepository)
            isAssignableFrom(TasksViewModel::class.java) ->
                TasksViewModel(tasksRepository, handle)
            else ->
                throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
        }
    } as T
}

Finish the ViewModelFactory changes by going one layer up, and pass ShortcutsRepository to the factory's constructor. Open Android Studio's file browser by going to Navigate > File and typing "FragmentExt.kt". Click the resulting Kotlin file located in the util package to open it in your IDE.

Replace the body of FragmentExt.kt with the following code:

fun Fragment.getViewModelFactory(): ViewModelFactory {
   val taskRepository = (requireContext().applicationContext as TodoApplication).taskRepository
   val shortcutsRepository = (requireContext().applicationContext as TodoApplication).shortcutsRepository
   return ViewModelFactory(taskRepository, shortcutsRepository, this)
}

Push a shortcut

With the ShortcutsRepository abstraction class available to the sample app's ViewModel classes, you update AddEditTaskViewModel, the ViewModel class responsible for creating notes, to push a dynamic shortcut each time a user creates a new note.

In Android Studio, open the class browser and type "AddEditTaskViewModel". Click the resulting Kotlin file to open it in your IDE.

First, add the ShortcutsRepository package to this class with the following import statement:

package com.example.android.architecture.blueprints.todoapp.addedittask

//Other import statements
import com.example.android.architecture.blueprints.todoapp.data.source.ShortcutsRepository

Next, add the shortcutsRepository class property by updating the class constructor with the following code:

AddEditTaskViewModel.kt

//...

/**
* ViewModel for the Add/Edit screen.
*/
class AddEditTaskViewModel(
   private val tasksRepository: TasksRepository,
   private val shortcutsRepository: ShortcutsRepository
) : ViewModel() {

    //...

With the ShortcutsRepository class added, create a new function, pushShortcut(), to call this class. Paste the following private function into the body of AddEditTaskViewModel:

AddEditTaskViewModel.kt

//...
private fun pushShortcut(newTask: Task) = viewModelScope.launch {
   shortcutsRepository.pushShortcut(newTask)
}

Finally, push a new dynamic shortcut whenever a task gets created. Replace the contents of the saveTask() function with the following code:

AddEditTaskViewModel.kt

fun saveTask() {
    val currentTitle = title.value
    val currentDescription = description.value

    if (currentTitle == null || currentDescription == null) {
        _snackbarText.value = Event(R.string.empty_task_message)
        return
    }
    if (Task(currentTitle, currentDescription).isEmpty) {
        _snackbarText.value = Event(R.string.empty_task_message)
        return
    }

    val currentTaskId = taskId
    if (isNewTask || currentTaskId == null) {
        val task = Task(currentTitle, currentDescription)
        createTask(task)
        pushShortcut(task)
    } else {
        val task = Task(currentTitle, currentDescription, taskCompleted, currentTaskId)
        updateTask(task)
    }
}

Test your code

We're finally ready to test our code! In this step, you push a voice enabled dynamic shortcut and inspect it using the Google Assistant app.

Create a preview

Creating a preview using the Google Assistant plugin enables your dynamic shortcuts to appear in Assistant on your test device.

Install the test plugin

If you do not already have the Google Assistant plugin, install it by following these steps in Android Studio:

  1. Go to **File > Settings (Android Studio > Preferences on MacOS).
  2. In the Plugins section, go to Marketplace and search for "Google Assistant".
  3. Install the tool and restart Android Studio.

Create the preview

Create a preview by following these steps in Android Studio:

  1. Click on Tools > Google Assistant > "App Actions Test Tool".
  2. In the App name box, define a name like "Todo List".
  3. Click Create Preview. If asked, review and accept the App Actions policies and terms of service.

App Actions Test Tool preview creation pane.

Figure 3. The App Actions Test Tool preview creation pane.

During testing, dynamic shortcuts you push to Assistant will appear in Assistant organized by the App name you provided for the preview.

Push and inspect a shortcut

Relaunch the sample app on your test device and perform the following steps :

  1. Create a new task with the title "Start codelab"
  2. Open Google Assistant app and say or type: "My shortcuts."
  3. Tap the Explore tab. You should see the sample shortcut.
  4. Tap the shortcut to invoke it. You should see the app launch with the name of the shortcut pre-populated in the filter box, making it easy to find the requested task item.

6. (Optional) Update and delete a shortcut

In addition to pushing new dynamic shortcuts at runtime, your app can update them to reflect the current state of your user content and preferences. It's good practice to update existing shortcuts whenever a user modifies the destination item, like renaming a task in our sample app. You should also delete a corresponding shortcut whenever the destination resource is removed to avoid displaying broken shortcuts to user.

Update a shortcut

Modify AddEditTaskViewModel to update a dynamic shortcut whenever a user changes the details of a task item. First, update the body of the class with the following code to add an update function that utilizes our repository class:

AddEditTaskViewModel.kt

private fun updateShortcut(newTask: Task) = viewModelScope.launch {
   shortcutsRepository.updateShortcuts(listOf(newTask))
}

Next, modify the saveTask() function to call our new method whenever an existing task is updated.

AddEditTaskViewModel.kt

// Called when clicking on fab.
fun saveTask() {
   // ...
   // Note: the shortcuts are created/updated in a worker thread.
   if (isNewTask || currentTaskId == null) {
       //...
   } else {
       //...
       updateShortcut(task)
   }
}

Test your code by relaunching the app and following these steps:

  1. Rename the title of your existing task item to "Finish codelab".
  2. Open the Google Assistant by saying, "Hey Google, my shortcuts."
  3. Tap the Explore tab. You should see an updated short label for your test Shortcut.

Remove a shortcut

Our sample app shortcuts should be removed whenever a user deletes a task. In the sample app, task deletion logic lives in the TaskDetailViewModel class. Before we update this class, we need to update ViewModelFactory again to pass shortcutsRepository into TaskDetailViewModel.

Open ViewModelFactory and replace the contents of its constructor method with the following code:

//...
class ViewModelFactory constructor(
       private val tasksRepository: TasksRepository,
       private val shortcutsRepository: ShortcutsRepository,
       owner: SavedStateRegistryOwner,
       defaultArgs: Bundle? = null
) : AbstractSavedStateViewModelFactory(owner, defaultArgs) {

   override fun <T : ViewModel> create(
           key: String,
           modelClass: Class<T>,
           handle: SavedStateHandle
   ) = with(modelClass) {
       when {
           isAssignableFrom(StatisticsViewModel::class.java) ->
               StatisticsViewModel(tasksRepository)
           isAssignableFrom(TaskDetailViewModel::class.java) ->
               TaskDetailViewModel(tasksRepository, shortcutsRepository)
           isAssignableFrom(AddEditTaskViewModel::class.java) ->
               AddEditTaskViewModel(tasksRepository, shortcutsRepository)
           isAssignableFrom(TasksViewModel::class.java) ->
               TasksViewModel(tasksRepository, handle)
           else ->
               throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
       }
   } as T
}

Next, open TaskDetailViewModel. Import the ShortcutsRepository module and declare an instance variable for it using the following code:

TaskDetailViewModel.kt

package com.example.android.architecture.blueprints.todoapp.taskdetail

...
import com.example.android.architecture.blueprints.todoapp.data.source.ShortcutsRepository


/**
* ViewModel for the Details screen.
*/
class TaskDetailViewModel(
       //...
       private val shortcutsRepository: ShortcutsRepository
   ) : ViewModel() {
...
}

Finally, modify the deleteTask() function to call shortcutsRepository to remove a shortcut based on its ID whenever a task with a corresponding taskId is deleted:

TaskDetailViewModel.kt

fun deleteTask() = viewModelScope.launch {
   _taskId.value?.let {
       //...
       shortcutsRepository.removeShortcutsById(listOf(it))
   }
}

To test your code, relaunch the app and follow these steps:

  1. Delete your test task.
  2. Rename the title of your existing task item to "Finish codelab".
  3. Open the Google Assistant by saying, "Hey Google, my shortcuts."
  4. Tap the Explore tab. Confirm that your test shortcut no longer appears.

7. Next steps

Congratulations! Thanks to you, users of our sample app can easily return to the notes they create by asking Assistant things like, "Hey Google, open my grocery list on ExampleApp". Shortcuts encourage deeper user engagement by making it easy for users to replay frequently used actions in your app.

What we've covered

In this codelab, you learned how to:

  • Identify use cases for pushing dynamic shortcuts in an app.
  • Reduce code complexity using repository, dependency injection and service locator design patterns.
  • Push voice-enabled dynamic shortcuts to user-generated app content.
  • Update and remove existing shortcuts.

What's next

From here, you can try making further refinements to your Task List app. To reference the finished project, see the repo –codelab-complete branch on GitHub.

Here are some suggestions for further learning about extending this app with App Actions:

To continue your Actions on Google journey, explore these resources:

Follow us on Twitter @ActionsOnGoogle to stay tuned to our latest announcements, and tweet to #appActions to share what you have built!

Feedback survey

Finally, please fill out this survey to give feedback about your experience with this codelab.