Многие из вас прекрасно знают, что такое Google Ассистент. Это голосовой помощник, подобный Siri, Алисе, Алексе и другим. Когда пользователь что-то говорит, Google Ассистент понимает это с помощью natural language understanding (NLU). NLU преобразует человеческую речь в специальную структуру данных, которую уже можно обработать.

У разработчиков Android есть возможность интегрировать NLU в свои приложения через специальный api, который называется App Actions. Точнее, существует два вида интеграции: мы можем получить и использовать Deep Link из речи пользователя или получить данные из приложения, не открывая его, и показать их прямо в Google Ассистенте через Slice.

Как работать с голосовым ассистентом

Когда пользователь отдаёт голосовую команду, он нажимает на кнопку и говорит, например: «Окей, Google. Хочу узнать мой текущий счёт в таком-то приложении». Google Ассистент распознаёт речь с помощью NLU и переводит её в специальную структуру данных. Затем в приложении ищется соответствие с одним из действий — Deep Link или Slice. Если найден Deep Link, из команды пользователя вычленяются данные, и пользователь видит какой-либо экран приложения. Если найден Slice, пользователь видит какой-либо элемент в Google Ассистенте на основе полученных из приложения данных.

А теперь подробнее об этом от разработчика. Упомянутая структура данных называется intent, и это не тот intent, который используют все Android-разработчики, а специальная структура, описанная в документации.

Есть несколько встроенных intent’ов, называемых Built-in intent (BII). Они разбиты по сферам: финансы, здравоохранение, спорт, еда и так далее. В этих сферах есть типичные действия, которые совершают пользователи. Например, в спорте это «начать тренировку», «начать бег», «закончить бег», «приостановить бег», «начать бег на 20 минут» и так далее. Список intent’ов постоянно расширяется, и мы можем их использовать.

Когда хотим добавить в приложение какое-то типичное действие, находим подходящий BII в документации. Потом прописываем его в actions.xml, который является, по сути, главным файлом по работе с App Actions, и в нём происходит маппинг. Далее указываем, какое действие (или fulfillment — так оно называется в документации) нужно совершить: Deep Link или Slice. Deep Link мы обрабатываем, достаём из него данные и открываем определённый экран. Slice мы можем показать в виде bubbles в самом Google Ассистенте, передав какие-то данные из приложения.

Настройка окружения для интеграции голосовых команд

Будет понятнее, если мы сделаем простой Hello World с голосовыми командами. Сначала нам нужно настроить окружение: в Android Studio создаём новый проект. Проект нужно сразу загрузить в Google Play Console и сохранить там как черновик.

Когда хотим опубликовать приложение с App Actions, фактически нужно пройти два этапа проверки. Первый этап — обычная публикация приложения в Google Play, про который вы, скорее всего, хорошо всё знаете. Второй этап — отдельная подгрузка actions.xml в Google Play Console. Он проводится отдельно и независимо от первого. При каждом изменении actions.xml нужно отдельно его публиковать с отдельным процессом его проверки. Только когда оба этапа пройдены, можно использовать приложение.

Чтобы до публикации протестировать, как в приложении работают App Actions, нужно:

  1. Сохранить проект в качестве черновика в Google Play Console без actions.xml

  2. В Android Studio установить плагин Test Tool для App Actions, который позволит загружать actions.xml в аккаунт Google Play Console

App Actions, описанные в actions.xml, будут работать только из-под нашего аккаунта. Поэтому везде должен использоваться один и тот же Google-аккаунт: в Android Studio, в Google Play Console, на реальном устройстве или эмуляторе, на которых будем тестировать  — только так App Actions смогут работать.

Простейшая голосовая команда по открытию экрана

Предварительные настройки завершены, вернёмся к проекту. В файле манифеста пропишем расположение файла android.xml (в ресурсной папке xml):

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

Файл actions.xml в простейшем варианте может выглядеть так:

<?xml version ="1.0" encoding ="utf-8"?>
<actions>
   <action intentName="actions.intent.GET_INVOICE">
       <fulfillment urlTemplate="https://zavanton.com/invoice" />
   </action>
</actions>

Как вы заметили, в этом файле перечисляются actions, которым соответствует fulfilment. В этом случае ожидаем от Google Ассистента встроенного intent GET_INVOICE — это финансовый intent, который вызывается голосовой командой «Окей, Google. Покажи мне мой текущий счёт». Когда пользователь произносит эту фразу в той или иной форме, Google Ассистент переводит её в этот intent, и мы можем его обработать в actions.xml.

В этом случае мы вызываем Deep Link с указанным url. Затем идёт обычная работа с диплинками: они прописываются в манифесте и обрабатываются уже в соответствующих Activity. То есть, получив Deep Link, мы можем, например, открыть какой-нибудь экран. Пример описания экрана с диплинками из манифеста:

<activity android:name=".MainActivity">
   <intent-filter>
       <action android:name="android.intent.action.MAIN" />
       <category android:name="android.intent.category.LAUNCHER" />
   </intent-filter>

   <!-- deep links -->
   <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="zavanton.com"
           android:scheme="https" />
   </intent-filter>
</activity>

Давайте посмотрим, как это выглядит в Android Studio

Открыта Android Studio, запущен эмулятор, и открыт плагин Test Tool. В проект уже добавлен файл actions.xml, и для его обновления на сервере Google Play Console нужно нажать Update Preview. После этого мы сможем запускать действия, прописанные в actions.xml. Для этого достаточно нажать Run и увидеть, как в эмуляторе или на реальном устройстве эмулируется ввод пользователя и открывается экран по диплинку.

Если хочется увидеть весь процесс, можно длительно нажать на кнопку Home на эмуляторе или реальном устройстве, а потом проговорить или написать фразу, которая будет распознана, и откроется тот же экран по диплинку.

А теперь давайте решим задачу посложнее. Подготовим два fulfilment разных типов: один будет в Deep Link передавать параметр, а другой будет работать со Slice.

Передача параметров через диплинки и работа с синонимами

Один параметр

Теперь пример посложнее. Передаём встроенный intent GET_THING с параметром. В документации у него указан один параметр thing.name. Поэтому нам останется прописать intent, параметр и указать, куда этот параметр подставляется в Deep Link.

Дальше из диплинка достаём параметры. В этом случае параметр — это поисковый запрос, по которому берём данные с сервера или из локальной базы и показываем их на экране, соответствующем диплинку.

<action intentName="actions.intent.GET_THING">
   <fulfillment urlTemplate="https://zavanton.com/search{?query}">
       <parameter-mapping
           intentParameter="thing.name"
           urlParameter="query" />
   </fulfillment>
</action>

Несколько параметров

В примере был один параметр. А как быть, если параметров несколько? Так же. Количество параметров зависит от используемого intent. Например, пользователь говорит: «Окей, Google. Переведи мне деньги завтра 100 рублей с текущего счёта на депозитный счёт». Фраза «переведи мне деньги» будет соответствовать заранее настроенному intent CREATE_MONEY_TRANSFER. А все остальные слова из этой фразы Google Ассистент попытается распарсить, воспроизвести, считать и передать в качестве соответствующих параметров. Ниже текст для actions.xml по настройке intent CREATE_MONEY_TRANSFER:

<action intentName="actions.intent.CREATE_MONEY_TRANSFER">
   <fulfillment urlTemplate="https://zavanton.com/payment{?transferMode,value,currency,moneyTransferOriginName,moneyTransferDestinationName,moneyTransferOriginProvidername,moneyTransferDestinationProvidername}">
       <!-- Eg. transferMode = "http://schema.googleapis.com/ReceiveMoney" -->
       <parameter-mapping
           intentParameter="moneyTransfer.transferMode"
           required="true"
           urlParameter="transferMode" />
       <!-- Eg. value = "100" -->
       <parameter-mapping
           intentParameter="moneyTransfer.amount.value"
           urlParameter="value" />
       <!-- Eg. currency = "USD" -->
       <parameter-mapping
           intentParameter="moneyTransfer.amount.currency"
           urlParameter="currency" />
       <!-- Eg. moneyTransferOriginName = "Credit card" -->
       <parameter-mapping
           intentParameter="moneyTransfer.moneyTransferOrigin.name"
           urlParameter="moneyTransferOriginName" />
       <!-- Eg. moneyTransferDestinationName = "Savings account" -->
       <parameter-mapping
           intentParameter="moneyTransfer.moneyTransferDestination.name"
           urlParameter="moneyTransferDestinationName" />
       <!-- Eg. moneyTransferOriginProvidername = "Example Provider" -->
       <parameter-mapping
           intentParameter="moneyTransfer.moneyTransferOrigin.provider.name"
           urlParameter="moneyTransferOriginProvidername" />
       <!-- Eg. moneyTransferDestinationProvidername = "Example Provider" -->
       <parameter-mapping
           intentParameter="moneyTransfer.moneyTransferDestination.provider.name"
           urlParameter="moneyTransferDestinationProvidername" />
   </fulfillment>

   <!--    fallback fulfillment -->
   <fulfillment urlTemplate="https://zavanton.com/payment" />
</action>

Незаполненные параметры

Хорошо, когда все необходимые нам параметры ассистент успешно распарсил и передал. А как быть, если некоторые параметры он не смог передать? Тогда мы обязаны предоставить Google Ассистенту fallback fulfillment в общем виде без параметров.

Допустим, была команда выполнить платёж, и параметры не считались. На этот случай по fallback fulfillment мы можем открыть экран «денежные переводы», и пользователь сам введёт эти параметры. Обращаю внимание, что можно предоставить несколько fulfillment, и они будут считываться сверху вниз. Первый fulfillment, который подходит, будет реализован, нижестоящие — пропущены. Соответственно, последним указываем наш fallback fulfillment без параметров.

Синонимы

Ещё есть возможность накладывать на получаемые параметры ограничения. Например, голосовая команда «Окей, Google. Открой мне список банковских карт в моём приложении» или «Окей, Google. Открой мне список счетов в моём приложении». Допустим, есть две фичи — список карт и список счетов. Но пользователь может по-разному их называть: банковские карты, просто карты, ещё какие-нибудь карты. Мы можем занести их все с помощью inline inventory и тега entity-set. В entity-set заносятся имена и синонимы, которые мы можем услышать от пользователя: «банковская», «банковская карта», «карта», «карточка» и так далее. Все эти синонимы будут приводиться к одному cards. Аналогично делаем для счетов, приводя их к accounts. В итоге у нас остались cards и accounts — два значения, которые можно использовать.

В fulfilment указан параметр feature, который ссылается через entitySetId на наборы значений cards и accounts. У cards и accounts есть параметр url с адресом Deep Link для каждого набора значений. В fulfillment в параметре urlTemplate прописано {@url}, а это значит, что в качестве Deep Link будет подставляться соответствующий url из cards или accounts — в зависимости от того, что скажет пользователь.

<action intentName="actions.intent.OPEN_APP_FEATURE">
   <!-- the fulfillment for specific features -->
   <fulfillment
       fulfillmentMode="actions.fulfillment.DEEPLINK"
       urlTemplate="{@url}" />

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

   <!-- the fallback fulfillment -->
   <fulfillment
       fulfillmentMode="actions.fulfillment.DEEPLINK"
       urlTemplate="https://zavanton.com/home" />
</action>

<entity-set entitySetId="myappfeature">
   <entity
       name="cards"
       url="https://zavanton.com/card-list" />
   <entity
       name="accounts"
       url="https://zavanton.com/account-list" />
</entity-set>

Заполнение при открытом приложении

У fulfilment есть атрибут requiredForegroundActivity. Что делает этот атрибут? При указании в нём activity срабатывает только при открытом приложении. Это может быть полезно в некоторых ситуациях.

Допустим, есть intent на перевод денег. В банковском приложении пользователь должен сначала авторизоваться. Если мы вызовем этот intent, нужно пройти авторизацию, перейти на нужный экран и так далее. Чтобы избежать сложностей, дадим возможность использовать intent только при открытом приложении.

Пользователь открывает приложение, переходит на нужный экран, нажимает Home и говорит: «Перевести на другой счёт такую-то сумму». Google Ассистент открыт, приложение открыто  — intent будет выполнен. Повторю, это достигается с помощью флага requiredForegroundActivity, в нём указываем активность, которая должна быть открыта.

Подробнее об этих и других параметрах можно почитать в документации.

Обратная связь для ассистента

Пользователь дал голосовую команду, Google Ассистент её обработал и передал в intent — после этого Google Ассистент не знает, насколько успешно мы смогли эту команду обработать.

Есть способ исправить эту ситуацию и дать обратную связь Google Ассистенту. Это делается с помощью библиотеки Firebase App Indexing. После обработки Deep Link нужно вызвать метод, который, в зависимости от успешности обработки, отправит true или false в наш Firebase:

private fun notifyGoogleAssistant(isActionHandled: Boolean) {
   intent.getStringExtra(Actions.ACTION_TOKEN_EXTRA)?.let { actionToken ->
           val actionStatus = if (isActionHandled) {
       Action.Builder.STATUS_TYPE_COMPLETED
   } else {
       Action.Builder.STATUS_TYPE_FAILED
   }
       val action = AssistActionBuilder()
               .setActionToken(actionToken)
               .setActionStatus(actionStatus)
               .build()

       // Send the end action to the Firebase app indexing.
       FirebaseUserActions.getInstance().end(action)
   }
}

Это рекомендуется делать, чтобы Google Ассистент знал, что мы успешно обрабатываем intent в приложении, и он и дальше может передавать Deep Link именно в наше приложение, а не в какое-либо другое. 

Настройка Slice для диалога в ассистенте

Далеко не всегда нужно открывать приложение по голосовой команде. Часто удобнее взять данные из приложения и показать их на экране диалога Google Ассистента. Это делается с помощью Slice.

Тестирование проходит так же, как и с Deep Link: в плагине Test Tool выбираем action, который соответствует Slice и нажимаем кнопку RUN. В эмуляторе видим, что приложение не открывается, а мы остаёмся в Google Ассистенте, и в нём появляется bubble — диалоговое окно из нашего приложения с нашими данными.

Это делается в том же файле actions.xml и похоже на то, что мы ранее описывали, за исключением другого fulfillmentMode, который установлен как actions.fulfillment.SLICE. FulfillmentMode существует два: либо Slice, либо Deep Link. Deep Link идёт по умолчанию, его можно и не указывать. Давайте разберём следующий кусок подключения Slice:

<!-- App Action with slice fulfillment -->
<action intentName="actions.intent.GET_ACCOUNT">
   <fulfillment
       fulfillmentMode="actions.fulfillment.SLICE"
       urlTemplate="content://com.zavanton.slices.account.provider{?accountName,description,providerName}">
       <parameter-mapping
           intentParameter="account.name"
           required="true"
           urlParameter="accountName" />
       <parameter-mapping
           intentParameter="account.description"
           urlParameter="description" />
       <parameter-mapping
           intentParameter="account.provider.name"
           urlParameter="providerName" />
   </fulfillment>

   <!-- the fallback fulfillment -->
   <fulfillment
       fulfillmentMode="actions.fulfillment.DEEPLINK"
       urlTemplate="https://zavanton.com/home" />
</action>

Тут тоже есть параметры, распознаваемые в Google Ассистенте и передаваемые в url. Чем же теперь будет url, если мы работаем не с Deep Link? Этот url будет authority контент-провайдер, через которого мы будем получать данные.

Вернёмся к Android-манифесту. Определим там контент-провайдер с authority, который соответствует url, указанном в actions.xml.

<provider
   android:name=".slices.AccountInfoProvider"
   android:authorities="com.zavanton.slices.account.provider"
   android:exported="true">
   <intent-filter>
       <action android:name="android.intent.action.VIEW" />
       <category android:name="android.app.slice.category.SLICE" />
   </intent-filter>
</provider>

Контент-провайдер — это класс, он наследуется от SliceProvider, будет использоваться Slice-менеджером и передаваться обратно в Google Ассистента, который будет его отображать.

В классе реализуем ряд методов, прежде всего метод onBindSlice(). В нём заложена логика получения данных. Из sliceUri получаем сказанное пользователем — это какие-то параметры. Их используем в запросе к локальной базе данных или к серверному api и в красивой форме отдаём.

override fun onBindSlice(sliceUri: Uri): Slice {
   Log.d("zavanton", "zavanton - onBindSlice: $sliceUri")
   // implement logic to get some slice from uri
   return list(requireNotNull(context), sliceUri, ListBuilder.INFINITY) {
       header {
           title = "Account Info"
           subtitle = "Some Subtitle"
           primaryAction = createOpenAppAction()
       }
       gridRow {
           cell {
               addTitleText("Some Title Text")
               addText("Some text")
           }
       }
   }
}

private fun createOpenAppAction(): SliceAction {
   val intent = Intent(context, MainActivity::class.java)
   return SliceAction.create(
           PendingIntent.getActivity(context, 0, intent, 0),
           IconCompat.createWithResource(context, R.drawable.abc_ic_star_black_16dp),
           ListBuilder.SMALL_IMAGE,
           "Open App"
   )
}

Повторим всю цепочку:

  1. Пользователь говорит «Окей, Google. Покажи мне мой текущий баланс из такого-то приложения»

  2. Google Ассистент подбирает соответствующий Built-in intent

  3. В actions.xml этот intent будет захвачен с fulfillmentMode = Slice

  4. Slice-менеджер анализирует url и по нему находит контент-провайдер

  5. Из контент-провайдера он возьмёт данные

  6. Затем передаст их обратно в Google Ассистент в виде слайса

При этом мы должны предусмотреть, что slice-менеджер должен иметь разрешение на работу с контент-провайдером. Например, в Application-классе надо дать ему разрешение на работу с контент-провайдером — просто даём разрешение. 

private fun grantAssistantPermissions() {
   getAssistantPackage()?.let { assistantPackage ->
           val sliceProviderUri = Uri.Builder()
           .scheme(ContentResolver.SCHEME_CONTENT)
           .authority(AccountInfoProvider.ACCOUNT_SLICE_AUTHORITY)
           .build()

       SliceManager.getInstance(this).grantSlicePermission(assistantPackage, sliceProviderUri)
   }
}

private fun getAssistantPackage(): String? {
   val resolveInfoList = packageManager?.queryIntentServices(
           Intent(VoiceInteractionService.SERVICE_INTERFACE), 0
   )
   return resolveInfoList?.firstOrNull()?.serviceInfo?.packageName
}

Нестандартные запросы через кастомные intent’ы

Последняя тема, на которой я хотел бы остановиться, — это кастомные intent’ы. Ранее я писал только про встроенные (build-in) intent, которые может понимать Google Ассистент. Но в последних версиях появилась возможность создавать свои intent. Это может быть полезно, когда есть пользовательский запрос, который не вписывается в сценарии из официальной документации.

Как это делается? В файле actions.xml указываем action с нашим кастомным intent с именем, которое сами придумаем, но по конвенции имя должно начинаться с custom.action. В Built-in intent «android.action», а здесь будет «custom.action».

Затем идёт важный атрибут queryPatterns  — он фактически будет ссылаться на список запросов, которые мы предполагаем услышать от пользователя. Например, мы хотим услышать от пользователя «Эй, Google, открой такое-то приложение и подпишись на такую-то услугу». Мы хотим услышать от пользователя одну и ту же команду в трёх вариациях. Соответственно, мы сформулировали три вариации и прописываем их в наших строковых ресурсах:

— Hey, Google, open DemoApp and subscribe me to SomeService.

— Hey, Google, open DemoApp and make a subscription to SomeService.

— Hey, Google, open DemoApp and enable service SomeService.

Создаём массив строк и в нём будем указывать эти три команды. Естественно, их может быть больше, и мы можем указать параметры, которые хотим поручить Google Ассистенту доставать из голосовых команд. В этом примере видим, что у нас один параметр — это сервис. Выделяем его с помощью такого знака $.

<string-array name="subscribe_to_financial_services">
   <item>subscribe me to $service1</item>
   <item>make a subscription to $service1</item>
   <item>enable service $service1</item>
</string-array>

Возвращаясь к actions.xml, пропишем, что параметр $service1 мы обрабатываем. Пропишем у него тип Теxt, и теперь мы можем использовать его для передачи в url.

<action
   intentName="custom.actions.intent.SUBSCRIBE_TO_FINANCIAL_SERVICE"
   queryPatterns="@array/subscribe_to_financial_services">
   <parameter
       name="service1"
       type="https://schema.org/Text" />
   <fulfillment urlTemplate="https://zavanton.com/services{?service}">
       <parameter-mapping
           intentParameter="service1"
           urlParameter="service" />
   </fulfillment>
</action>

Так работают кастомные intent, они очень похожи на build-in intent. В них мы можем прописать собственную логику. Тем не менее у кастомных intent есть ограничения:

  • Ограниченное количество типов параметров, которые мы можем передавать: текст, число, дата и время

  • Может быть всего два текстовых параметра

  • В списке команд queryPatterns нужно указывать команды дословно — это самое большое ограничение. Да, Google Ассистент будет воспринимать их дословно. Если мы какой-то кейс упустили, ассистент ещё не настолько умён, чтобы правильно обработать наш кастомный intent

Из-за последнего ограничения лучше использовать built-in intent — благодаря более гибкому и мощному механизму считывания команд пользователя. Но если у вас какой-то редкий случай, вполне можно воспользоваться кастомным intent, но очень хорошо продумать, какие формулировки хочется услышать от пользователя.

Заключение

Это довольно интересная технология. На моём примере можете ещё раз посмотреть в репозитории.

В нём собирается весь код. Немножко пришлось повозиться с настройкой окружения.

Самое большое ограничение в технологии — она заточена под английский язык. Есть встроенные intent, которые обрабатывают другие языки, но русского пока не нашёл. Планы по локализации пока неясны. Будем надеяться, что, если Google не похоронит эту технологию раньше времени, она будет развиваться, и мы сможем её использовать в нашем приложении, заточенном на русский. Но если ваше приложение ориентировано на английский язык, можно уже сейчас в продакшене пробовать какие-то фичи и экспериментировать с интентами от Google.

Комментарии (7)


  1. otchgol
    10.12.2021 08:19
    +1

    Забавно. Только позавчера в очередной раз пробовал заставить рабоать <fulfillment urlTemplate="intent:#Intent; и в очередно раз отчаялся. Может только deeplink работает. Попробую еще раз.

    UPD Не судьба. Может в статье какое важное вошебство опущено, но у меня не работает вызов тривиального встроенного GET_THING вводит в ступор ассистента с его беспонечным не нашел. Видно не судьба.


    1. Solo4
      10.12.2021 09:24
      +1

      Я в своё время тоже водился, завёл этот интент только на английском языке, а после только понял, что ассистент с английским и другими языками дружит, но не с русским)


      1. otchgol
        10.12.2021 09:51

        Да безразличен мне язык в общем-то. Просто поиграться даже не могу его запустить. Что-то перемудрили гуглы с ассистентом.


      1. yatagarasu
        10.12.2021 22:27

        Игрался я с ассистентом на новом телефоне, обнаружил странную особенность, если интерфейс телефона установлен в английский то ассистент перестает воспринимать команды на русском, хотя русский те что прекрасно распознает. Подозреваю что это прибито к языку интерфейса приложения.


    1. zavanton Автор
      10.12.2021 11:09
      +1

      Думаю, здесь основная проблема в настройке окружения, еще нужно пробовать и на реальном устройстве и на эмуляторе. В гугл ассистент можно вводить сообщения не только голосом, но и текстом. Весь код из статьи для сравнения можно посмотреть здесь https://github.com/zavanton123/app-actions-demo


      1. otchgol
        10.12.2021 14:29
        +1

        Совершенно согласен. Ваше приложение у меня частично что-то делает. Но не get_thing. Он работает у вас?


        1. zavanton Автор
          10.12.2021 15:51
          +1

          Насколько я помню, у меня все примеры из статьи работали, правда я проверял работоспособность около полугода назад, когда делал доклад к конференции https://www.youtube.com/watch?v=fXO0v9KutAE - там в видео видны данные, которые передаются в плагин App Actions Test Tool, возможно это поможет. Хотя возможно, за это время функциональность get_thing поменялась или сломалась.