Todos os artigos

Mostrando conteúdo rápido no Android: App Actions & Slices

Google Assistant

Google Assistant - Fonte: Pixabay

Atualmente está cada vez mais frequente o uso de assistentes virtuais, principalmente em smartphones. Eu, por exemplo, sempre apago a luz do meu quarto por comando de voz, dentre outras inúmeras possibilidades. Neste cenário, os aplicativos Android conseguem obter ainda mais engajamento dos usuários através de respostas rápidas de seus conteúdos integrando com o Google Assistant. Para isso, a Google lançou o App Actions.

Antes de continuar, é necessário pontuar que essa ferramenta, no dia da publicação deste artigo, se encontra em Developer Preview e tem algumas limitações: apenas um conjunto limitado de ações está disponível e para algumas linguagens específicas (que varia de acordo com a ação). Veja a lista de ações reconhecidas (Built-in intents) aqui.

Como funciona

Na percepção do usuário, uma App Action é como se fosse um “atalho” do Google Assistant para o app. Na realidade, o que acontece é que o Assistant faz o reconhecimento da requisição do usuário e interpreta esses dados como um Intent (necessariamente precisam ser aqueles Built-in, conforme mencionado anteriormente) com seus fullfilments (url, parâmetros, etc.) relacionados. Após a interpretação, é feito um mapeamento com as ações correspondentes aos Intents que estiverem registrados no arquivo de ações (actions.xml) do app.

Ao identificar um Intent relacionado a uma(s) ação(ões), o Assistant interpretra os parâmetros como uma entidade - que deve seguir o padrão schema.org - e também a url (deep link) mapeado no arquivo de ações do app. Por fim o sistema identifica e direciona o usuário direto para o conteúdo do app. No caso do uso de Slice, a informação é mostrado diretamente no Assistant (Veremos melhor mais pra frente).

Comando no assistant Resultado no App
Exemplo de funcionamento do App Actions

Projeto

Como exemplo de código para este artigo, criei um projeto de assistente de receitas, em que você pergunta ao Google Assistant coisas como “Olá Google, abrir receita de Pão de queijo” e assim é mostrado um slice do app diretamente no assistant. Para dispositivos em que os slices não estiverem disponíveis é apresentado uma Activity com os detalhes da receita.

Os dados das receitas foram alimentados por um json presente no Gist. Para facilitar um eventual escalonamento nos dados, pensando numa possibilidade de adicionar novas receitas futuramente, importei esse json para um banco de dados no Firestore do Firebase.

Resumindo: o app recebe o nome da receita como parâmetro do assistant, faz a busca no Firestore e, assim que encontrado, mostra ao usuário o conteúdo.

Configurações iniciais

Antes de começar, precisamos configurar o ambiente para conseguir testar as ações que implementarmos.

Criar um projeto na Playstore

Para que as ações sejam reconhecidas pelo Google Assistant (mesmo em teste) é necessário criar um projeto na Playstore e subir pelo menos um build (APK ou aab) com o arquivo das ações do app registrado no Manifest. Não é necessário publicar o app, basta ter um artefato salvo.

Para mais informações sobre como inserir um app na Playstore, veja na documentação.

Instalar plugin para testar as ações

Para rodar os testes, antes de publicar o app, é necessário instalar o plugin App Actions Test Tool no Android Studio. Para saber como instalar veja aqui. Essa ferramenta permite configurar os parâmetros da ação a ser testada.

É importante pontuar que esse plugin tem algumas restrições:

  • Funciona apenas com dispositivos físicos
  • Suporta apenas algumas localidades (en-US, en-GB, en-CA, en-IN, en-BE, en-SG, e en-AU). Com exceção de alguns built-in intents que aceitam algumas outras localidades, mas ainda bem limitado.

Após instalado, também é necessário fazer o login no Android Studio com a mesma conta do dispositivo que você irá usar para os testes.

App Actions Test Tool

App Actions Test Tool - Página de instalação do plugin no Android Studio.

Por fim, é necessário configurar quais telas do app devem tratar os deep links transferidos do assistant. No caso do projeto, foi configurado apenas que o link recipes://detail abre a tela de detalhe da receita:

<!-- AndroidManifest.xml -->
<activity android:name=".RecipeDetailActivity">
    <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:host="detail"
            android:scheme="recipes" />
    </intent-filter>
</activity>

Para adicionar suporte a deep link, basta adicionar dentro da tag <activity> desejada no manifest as configurações de <intent-filter> com <action> do tipo android.intent.action.VIEW e com as categorias android.intent.category.DEFAULT e android.intent.category.BROWSABLE (essa última é bem importante para o funcionamento das ações). Além disso é necessário definir o schema (protocolo) e o host da url que abrirá essa activity. É possível adicionar também definições de parâmetros esperados e path da url, mas não é necessário no momento. Para mais informações sobre deep links em Android, veja na documentação.

Configurando as ações

Com todas as configurações preliminares finalizadas, vamos inserir nossa primeira ação. O primeiro passo é adicionar o arquivo actions.xml dentro da pasta res/xml. É possível criar esse arquivo de forma automática no Android Studio: Arquivo > Novo > XML > Arquivo XML App Actions. Em seguida, determinar as ações possíveis do app nesse local:

<!-- actions.xml -->
<actions>
    <action intentName="actions.intent.OPEN_APP_FEATURE">
        <fulfillment urlTemplate="recipes://detail{?recipe}">
            <parameter-mapping
                intentParameter="feature"
                urlParameter="recipe" />
        </fulfillment>
    </action>
</actions>

Inicialmente, configurei dessa forma a ação do app. O primeiro ponto é relacionado ao intentName que precisa de ser um daqueles Built-in intents já mencionados anteriormente. No caso deste app de receitas não existe um intent pré-definido de “ver receita” então usei o genérico que é “abrir funcionalidade” (actions.intent.OPEN_APP_FEATURE). Na documentação desta ação, precisamos obter apenas um parâmetro do intent, que é o feature. Neste caso, os parâmetros acabam se resumindo em apenas uma string, mas poderia ser qualquer estrutura de dados desde de que se encaixe no padrão schema.org. Por exemplo, outros intents exigem uma estrutura fixa, como por exemplo actions.intent.ORDER_MENU_ITEM que exige que os parâmetros sejam correspondentes ao schema de MenuItem.

Para facilitar a obtenção das receitas no Firestore, configurei um padrão para o parâmetro de feature em que o texto falado pelo usuário é mapeado para o id do documento no Firestore relativo a receita escolhida.

<!-- actions.xml -->
<actions>
    <action intentName="actions.intent.OPEN_APP_FEATURE">
        <fulfillment urlTemplate="recipes://detail{?recipe}">
            <parameter-mapping
                intentParameter="feature"
                urlParameter="recipe" />
        </fulfillment>

        <parameter name="feature">
            <entity-set-reference entitySetId="RecipeEntitySet" />
        </parameter>
    </action>

    <entity-set entitySetId="RecipeEntitySet">
        <entity
            name="@string/frozen_yogurt"
            alternateName="@array/frozen_yogurt"
            identifier="2gbkANgwMJYMUnYgoRHn" />
        <entity
            name="@string/cheese_bread"
            alternateName="@array/cheese_bread"
            identifier="6aRoFUbVuMp3EP48uO8M" />
        <entity
            name="@string/mac_and_cheese"
            alternateName="@array/mac_and_cheese"
            identifier="7BnVOHJMxqHzUNxnYY5D" />
        ...
    </entity-set>
</actions>

Agora o parâmetro feature está configurado para aceitar qualquer valor dentro do conjunto RecipeEntitySet. Repare que cada <entry> possui o name que é um nome principal que o assistant vai identificar a entidade; também tem alternateName que são sinônimos ou nomes alternativos da entidade, por exemplo: pão de queijo, biscoito de queijo, etc. O identifier é o identificador do documento desta entidade no Firestore. Ao fazer o mapeamento dos parâmetros com as entidades suportadas, o Assistant inicia o intent substituindo o identifier no lugar da feature e aí cabe ao app fazer o tratamento da informação.

Existem diversas possibilidades de configurações de parâmetros e entidades. Por exemplo, no lugar de um identifier, é possível configurar uma url para cada entidade, e aí dentro de fullfilment não será necessário tratar o parameter-mapping pois já foi feito na entidade.

Após configurar o arquivo actions.xml é necessário registrá-lo no AndroidManifest.xml:

<!-- AndroidManifest.xml -->
<application ...>
  ...
  <meta-data
    android:name="com.google.android.actions"
    android:resource="@xml/actions" />
</application>

Testando as ações

Para testar as novas ações, basta inicial o plugin do App Actions Test Tool que foi instalado anteriormente. Ao iniciar, será pedido um nome e uma localidade para criar um preview. Ambos os campos são opcionais. Lembrando que a localidade precisa ser uma daquelas já mencionadas no início do artigo.

App Actions Test Tool - Start

App Actions Test Tool — Create preview

Após criar o preview, a ferramente já obtém todas as ações configuradas do app, que no nosso caso agora é apenas actions.intent.OPEN_APP_FEATURE. No campo feature vamos inserir o nome da “funcionalidade” que ficou configurado como sendo o nome da receita. Repare que a localidade precisa ser em inglês, então coloquei como feature “cheese bread”.

App Actions Test Tool Preview Resultado no App
Rodando uma ação através do Android App Action Test Tool

Ao clicar em run o plugin inicia o assistant do dispositivo com o intent de abrir funcionalidade e com o parâmetro recipe=<id_recipe> pois foi o mapeamento que ele fez com as configurações que colocamos no preview. Assim que é recebido pelo dispositivo, ele já reconhece que existe o app responsável por tratar essa ação e abre diretamente a tela de detalhe da receita.


Android Slices

Aproveitando que já fizemos uma forma rápida do usuário acessar as funcionalidades do app através das ações, porque não mostrar o conteúdo de forma mais direta e simples? Para isso, precisamos configurar um Android Slice. Que é uma forma dinâmica e interativa de mostrar o conteúdo do app diretamente no assistant, sem a necessidade de abrir o app efetivamente. Também é possível tomar algumas ações rápidas sem estar dentro de todo o contexto da aplicação.

Existem diversos templates de slices que podem ser usados conforme a necessidade do aplicativo. Além disso também é possível inserir dados de forma dinâmica através de uma conexão com servidor, controladores de entrada do usuário, live data, e outras diversas funcionalidades. Para saber mais sobre os Slices do Android, veja na documentação.

Instalar visualizador de slices

Antes de começar a criar os slices precisamos baixar um app que vai nos permitir visualizar esses componentes.

Primeiro, é preciso baixar o app na página de releases do Slice Viewer. Em seguida, basta instalar o APK no dispositivo. Se preferir use diretamente o comando do adb: adb install -r -t slice-viewer.apk

Criar provedor de slices

O próximo passo agora é criar um SliceProvider que pode ser feito diretamente pelo Android Studio: Arquivo > Novo > Outros > Slice Provider. Lembrando também que é necessário registrar esse provedor no AndroidManifest.xml:

<!-- AndroidManifest.xml -->
<application ...>
 ...
 <provider
     android:name=".app.RecipeSliceProvider"
     android:authorities="${applicationId}"
     android:exported="true">
     <intent-filter>
         <action android:name="android.intent.action.VIEW" />

         <category android:name="android.app.slice.category.SLICE" />

         <data
             android:host="${applicationId}"
             android:pathPattern="/detail"
             android:scheme="content" />
     </intent-filter>
 </provider>
</application>

No caso, o RecipeSliceProvider possui apenas um atributo que é uma lista mutável de receitas e, a cada nova ação acionada pelo usuário que mostra o assistant, é feito uma busca no Firestore com o id obtido na uri e, quando retornado sucesso é mostrado um conteúdo resumido da receita no slice vinculado. A decisão de usar uma lista é porque podem existir inúmeros slices aparecendo juntos em uma sessão do assistant. Por isso ficamos com diversas referências que são associadas por seus identificadores de forma que cada slice mostra o conteúdo correto da receita que corresponde.

/** RecipeSliceProvider.kt **/
class RecipeSliceProvider : SliceProvider() {

    override fun onCreateSliceProvider() = true

    override fun onMapIntentToUri(intent: Intent?): Uri {
        var uriBuilder: Uri.Builder = Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
        if (intent == null) return uriBuilder.build()
        val data = intent.data
        val dataPath = data?.path
        if (data != null && dataPath != null) uriBuilder = uriBuilder.path(dataPath)
        context ?: return uriBuilder.build()
        uriBuilder = uriBuilder.authority(context!!.packageName)

        return uriBuilder.build()
    }
    
    override fun onBindSlice(sliceUri: Uri): Slice? {
        val context = context ?: return null
        return if (sliceUri.path == "/") {
          //TODO("Return slice with content")

        } else {
          //TODO("Return slice with not found error")
        }
    }
    
    override fun onSlicePinned(sliceUri: Uri?) {
      //TODO("get dynamic initial data, subscribe to any observers")
    }

    override fun onSliceUnpinned(sliceUri: Uri?) {
        super.onSliceUnpinned(sliceUri)
        //TODO("tear down every observer and remove unecessary resources")
    }
}

Neste provedor temos alguns pontos de atenção: onCreateSliceProvider é usado para iniciar qualquer objeto que seja necessário e retornar true caso o provedor tenha sido criado com sucesso, caso contrário, retorna false.

A implementação deonMapIntentToUri é necessária apenas no caso em que o app precisa obter e mapear as requisições de url e converter urls comuns em content uri. No caso desse app, o mapeamento do slice foi feito diretamente via um content uri, então não foi necessária toda essa implementação.

O método onBindSlice é responsável por construir o slice e vincular os dados necessários. Também é neste método que fazemos o mapeamento do conteúdo, através do path do sliceUri.

/** RecipeSliceProvider.kt **/
override fun onBindSlice(sliceUri: Uri): Slice? {
    val context = context ?: return null
    val id = sliceUri.getQueryParameter("recipe")
    val resource = getResource(id)
    return if (sliceUri.path == "/detail") {
        when (resource) {
            is Resource.Success -> createContentSlice(context, sliceUri, resource.data)
            is Resource.Error -> createErrorSlice(context, sliceUri)
            else -> createLoadingSlice(context, sliceUri)
        }

    } else {
        createErrorSlice(context, sliceUri)
    }
}

Em onSlicePinned o slice foi atrelado a um serviço e está prestes a ser exibido pro usuário. Neste momento é hora de buscar os dados do servidor, sobrescrever para algum observable, ou ações semelhantes, quando necessário. Ao final, quando obter os dados necessários, basta chamar context.contentResolver.notifyChanged(sliceUri, null) para que seja atualizado o onBindSlice com o novo conteúdo.

/** RecipeSliceProvider.kt **/
override fun onSlicePinned(sliceUri: Uri?) {
    val id = sliceUri.getQueryParameter("recipe")
    FirestoreManager.getRecipeById(id) { task ->
        if (task.isSuccessful && task.result?.exists() == true) {
            val doc = task.result
            recipes.add(Resource.Success(doc?.data?.toRecipe(doc.id)))
            context?.contentResolver?.notifyChange(sliceUri, null)
        } else {
            recipes.add(Resource.Error(id))
            task.exception?.printStackTrace()
            context?.contentResolver?.notifyChange(sliceUri, null)
        }
    }
}

Para o caso exclusivo deste app e para facilitar o desenvolvimento, foi usado o SDK puro do Firestore para obtenção dos dados, de forma que foram usados apenas callbacks sem interação com live data, e observables por exemplo.

Por fim, no onSliceUnpinned é a hora de remover os observers e fazer qualquer limpeza para evitar vazamentos de memória. No caso do app de receitas, é feito apenas a limpeza dos dados dentro da lista recipes.

Usar templates para slices

Os Slices são construídos através de um sistema de lista (ListBuilder) que pode ter diferentes tipos de formato de linhas. O primeiro é o cabeçalho (HeaderBuilder) que possuem as informações principais do slice e também é o conteúdo mostrado quando o slice está no modo de visualização compacto. Outro formato é a linha em si (RowBuilder) para um conteúdo mais simples, e podendo ter uma ou mais ações rápidas atreladas. De forma semelhante tem o formato de grid (GridBuilder) que mostra o conteúdo em diferentes colunas dentro da mesma linha. Também é possível adicionar uma barra de progresso ou um slider através do RangeBuilder. Para mais informações sobre templates dos slices veja na documentação.

Para o app de receitas usei o HeaderBuilder para mostrar o nome da receita no modo reduzido do slice e nas outras partes, foi usado o RowBuilder por ser um conteúdo mais simples e único, precisando apenas de linhas mesmo. Contudo, foi configurado três tipos de slices: um para ser exibido enquanto os dados são carregados, outro para quando a receita foi retornada e outro para o caso de ter retornado algum erro ou não ter sido encontrada a receita.

/** RecipeSliceProvider.kt **/
private fun createLoadingSlice(context: Context, sliceUri: Uri) =
  list(context, sliceUri, ListBuilder.INFINITY) {
      header {
          setTitle(context.getString(R.string.loading_recipe), true)
      }
}

Para os slices é importante que apareça alguma informação instantânea para o usuário mesmo ainda não tendo todos os dados a serem mostrados .. por isso, foi criado o slice para mostrar que o conteúdo está carregando.

/** RecipeSliceProvider.kt **/
private fun createContentSlice(context: Context, sliceUri: Uri, recipe: Recipe?) =
  if (recipe != null)
    list(context, sliceUri, ListBuilder.INFINITY) {
      header {
          setTitle(recipe.name.orEmpty(), false)
          subtitle = recipe.baseIngredients?.joinToString().orEmpty()
          summary = recipe.baseIngredients?.joinToString().orEmpty()
          primaryAction = createDetailAction(recipe)
      }
      gridRow {
          cell {
              val bitmap = Glide.with(context)
                  .asBitmap()
                  .load(recipe.image)
                  .submit()
                  .get()
              val image =
                  IconCompat.createWithBitmap(bitmap)
              addImage(image, ListBuilder.LARGE_IMAGE)
          }
      }
      row {
          title = context.getString(R.string.directions)
          subtitle = recipe.directions.orEmpty()
      }
  } else createErrorSlice(context, sliceUri)

Para o carregamento da imagem da receita foi utilizada a biblioteca Glide que transforma uma url de imagem em um bitmap no android e, com isso foi gerado o IconImage necessário para ser inserido no slice.

/** RecipeSliceProvider.kt **/
private fun createErrorSlice(context: Context, sliceUri: Uri) =
  list(context, sliceUri, ListBuilder.INFINITY) {
      header {
          title = context.getString(R.string.error_recipe_not_found)
          primaryAction = createAppAction()
      }
  }

Por fim, repare que em diversos momentos foi configurado também uma primaryAction que é uma SliceAction na qual possui informações de intents e pending intents.

/** RecipeSliceProvider.kt **/
private fun createDetailAction(recipe: Recipe?) =
  createAction(RecipeDetailActivity.intent(context, recipe))

private fun createAppAction() = 
  createAction(Intent(context, MainActivity::class.java))

private fun createAction(intent: Intent?): SliceAction =
  SliceAction.create(
      PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT),
      IconCompat.createWithResource(context, R.drawable.ic_launcher_foreground),
      ListBuilder.ICON_IMAGE,
      context?.getString(R.string.open_app).orEmpty()
  )

Testar slice no dispositivo

Para testar no Slice Viewer instalado anteriormente, é necessário criar uma nova configuração de execução no Android Studio.

Slice run configuration

Adicionar nova configuração de execução para mostrar o Slice no Slice Viewer

Nomeie-a como preferir e em nas opções de lançamento (Launch Options) selecione o modo URL e na url coloque slice-content://<application_id>/path de acordo com o que você configurou no SliceProvider dentro do AndroidManifest.xml. No caso do app de receitas, também foi necessário adicionar o parâmetro recipe porque precisamos dele para saber qual a receita mostrar pro usuário. Após adicionado, basta rodar essa nova configuração.

Slice carregando Slice de conteúdo Slice de erro
Slice Viewer — Exemplos de slice

Configurar ação para usar o slice

Finalmente, vamos configurar o arquivo actions.xml para mostrar o slice criado. Para isso, basta adicionar um novo fullfiment dentro da ação com fullfilmentMode="actions.fullfilment.SLICE e o urlTemplate de acordo com o que ficou no AndroidManifest.

<!-- actions.xml -->
<actions>
    <action intentName="actions.intent.OPEN_APP_FEATURE">
        <fulfillment
            fulfillmentMode="actions.fulfillment.SLICE"
            urlTemplate="content://com.anacoimbra.android.recipes/detail{?recipe}">
            <parameter-mapping
                intentParameter="feature"
                required="true"
                urlParameter="recipe" />
        </fulfillment>
      ...
  </action>
  ...
</actions>

Pronto! Agora já está configurado para abrir o slice direto no Google Assistant. Para testar essa parte agora, basta abrir o Android App Actions Test Tool novamente e, se tiver um preview já criado, clique em Update Preview para que a ferramenta possa atualizar suas informações com as alterações feitas em actions.xml.

Slice on assistant

Slice no Google Assistant

Considerações finais

Implementar ações e slices para o app pode ser algo bem interessante que ajuda muito no engajamento e na interação do usuário. Contudo, muitas coisas ainda estão em fase experimental, por isso algumas questões ainda podem se alterar e tem muito a ser aprimorado.

De qualquer forma, é uma funcionalidade incrível que vale a pena testar e aprender pois está cada vez mais comum obtermos conteúdo através de assistentes virtuais e ter isso a favor do nosso aplicativo pode ser um alto ganho de usabilidade e negócio.

O código completo do projeto abordado neste artigo pode ser encontrado no Github.