Голосовизация приложения (beta)
ТВ приложение может быть интегрировано с голосовым помощником Алисой, что позволяет пользователю управлять интерфейсом с помощью голосовых команд: нажимать на элементы приложения, управлять воспроизведением и вызывать функции приложений (например, поиск).
Возможность пока не поддерживается для всех разработчиков приложений. Если вы хотите протестировать голосовизацию приложений — подайте заявку.
- Для корректной работы голосового управления приложение должно обязательно поддерживать спецификацию доступности (accessibility).
- Приложения с проигрываемым контентом (музыка, видео, трансляции) должны поддерживать медиа-сессии.
- Реализовать продвинутые возможности для управления голосом (поиск, переходы между разделами и т. д.) можно с помощью технологии AppFunctions.
Спецификация доступности (accessibility)
Чтобы пользователи могли нажимать на кнопки голосом, переключаться по разделам, управлять воспроизведением и т. д., приложение должно быть адаптировано в соответствии с рекомендациями по разметке доступности от Google. Это необходимо для того, чтобы голосовой помощник мог корректно обнаруживать и взаимодействовать с элементами интерфейса.
Что необходимо реализовать
Из рекомендаций по доступности особенно важно реализовать следующие требования:
-
Пользовательский элемент управления
View
, на который можно нажимать или переводить на него фокус, должен корректно сообщать об этом службе доступности (Accessibility). Это позволяет голосовому помощнику правильно распознавать и управлять такими элементами. -
Все кликабельные или фокусируемые изображения и иконки должны иметь заданный
contentDescription
. Например, иконка поиска в верхнем меню должна иметьcontentDescription="Search button"
или аналогичное описание. Название фильма или сериала, отображаемое в интерфейсе, должно быть также доступно черезcontentDescription
.
Подробнее о том, как реализовать доступность в приложении, можно узнать в официальных гайдах Google для View, Custom View или Jetpack Compose.
Как проверить
Чтобы проверить текущую разметку в приложении через UIAutomator:
-
Запустите приложение.
-
Откройте нужный экран.
-
Выполните следующую команду:
adb shell uiautomator dump
-
Откройте сгенерированный файл
/sdcard/window_dump.xml
и изучите разметку.
Примеры корректной реализации карусели с карточками фильмов
Откроем экран приложения, на котором отображается карусель с карточками фильмов.
Запустим команду uiautomator
, получим XML-файл с описанием интерфейса и найдем в нем, например, второй элемент из карусели:
<node index="1" text="" resource-id="" class="android.widget.FrameLayout" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[708,614][1214,1016]">
<node index="0" text="" resource-id="ru.kinopoisk.yandex.tv:id/composeView" class="androidx.compose.ui.platform.ComposeView" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[708,614][1214,1016]">
<node index="0" text="" resource-id="" class="android.view.View" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[708,614][1214,1016]">
<node index="0" text="" resource-id="" class="android.view.View" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[710,616][1212,1014]">
<node index="0" text="" resource-id="" class="android.view.View" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[710,616][1212,870]"/>
<node index="1" text="1 ч 53 мин" resource-id="" class="android.widget.TextView" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[1081,849][1200,870]"/>
<node index="2" text="" resource-id="" class="android.widget.ProgressBar" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[710,870][1212,918]"/>
<node index="3" text="Джентльмены" resource-id="" class="android.widget.TextView" package="ru.kinopoisk.yandex.tv" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[710,914][1188,946]"/>
</node>
</node>
</node>
</node>
Можно сделать следующие выводы:
-
элемент кликабелен (
clickable="true"
); -
у карточки присутствует текстовое описание.
Это означает, что элемент корректно передает информацию в службу доступности, и Алиса сможет его распознать и активировать.
Пример некорректной реализации карусели с карточками фильмов
Откроем экран приложения, на котором отображается карусель с карточками фильмов.
Запустим команду uiautomator
, получим XML-файл с описанием интерфейса и найдем в нем, например, первый элемент из карусели:
<node NAF="true" index="5" text="" resource-id="" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[1910,824][1920,1080]">
<node index="0" text="" resource-id="ru.amediateka:id/poster" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[1910,868][1920,1080]">
<node index="0" text="" resource-id="ru.amediateka:id/main_image" class="android.widget.ImageView" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[1910,868][1920,1080]"/>
</node>
</node>
Можно сделать следующие выводы:
-
элемент кликабелен (
clickable="true"
); -
у карточки отсутствует текстовое описание
contentDescription
илиtext
.
Это означает, что элемент не передает необходимую информацию в службу доступности, и Алиса не сможет его распознать или активировать.
Пример некорректной реализации элементов бокового меню
Откроем любой экран приложения, на котором отображается боковое меню.
Запустим команду uiautomator
, получим XML-файл с описанием интерфейса и найдем в нем элементы бокового меню:
<node index="0" text="" resource-id="ru.amediateka:id/main_menu_fragment" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][84,1080]">
<node index="0" text="" resource-id="ru.amediateka:id/list" class="androidx.recyclerview.widget.RecyclerView" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,162][84,918]">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,198][84,282]">
<node index="0" text="" resource-id="ru.amediateka:id/icon" class="android.widget.ImageView" package="ru.amediateka" content-desc="Amediateka" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[18,222][66,258]"/></node>
<node index="1" text="" resource-id="" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,318][84,402]">
<node index="0" text="" resource-id="ru.amediateka:id/icon" class="android.widget.ImageView" package="ru.amediateka" content-desc="Amediateka" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[18,342][66,378]"/></node>
<node index="2" text="" resource-id="" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,438][84,522]">
<node index="0" text="" resource-id="ru.amediateka:id/icon" class="android.widget.ImageView" package="ru.amediateka" content-desc="Amediateka" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[18,462][66,498]"/></node>
<node index="3" text="" resource-id="" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,558][84,642]">
<node index="0" text="" resource-id="ru.amediateka:id/icon" class="android.widget.ImageView" package="ru.amediateka" content-desc="Amediateka" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[18,582][66,618]"/></node>
<node index="4" text="" resource-id="" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,678][84,762]">
<node index="0" text="" resource-id="ru.amediateka:id/icon" class="android.widget.ImageView" package="ru.amediateka" content-desc="Amediateka" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[18,702][66,738]"/></node>
<node index="5" text="" resource-id="" class="android.widget.FrameLayout" package="ru.amediateka" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,798][84,882]">
<node index="0" text="" resource-id="ru.amediateka:id/icon" class="android.widget.ImageView" package="ru.amediateka" content-desc="Amediateka" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[18,822][66,858]"/>
</node>
</node>
</node>
Можно сделать следующие выводы:
-
элементы некликабельны (
clickable="false"
); -
у элементов отсутствует текстовое описание
contentDescription
илиtext
.
Элементы нельзя отличить друг от друга и понять, для чего они нужны. Они не передают необходимую информацию в службу доступности, и Алиса не сможет их распознать или активировать.
Медиа-сессии
Для видео-контента необходимо реализовать поддержку медиа-сессии. Это позволяет пользователям Яндекс ТВ Станции управлять воспроизведением — ставить контент на паузу, возобновлять просмотр и перематывать видео.
Чтобы реализовать медиа-сессию перед началом воспроизведения видео-контента, создайте MediaSession
и установите callback
и флаги:
session = MediaSession(this, "MusicService").apply {
setCallback(MediaSessionCallback())
setFlags(
MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS
)
}
Подробнее о том, как создавать медиа-сессии и управлять мультимедиа с их помощью, можно узнать в официальном гайде Google.
Технология AppFunctions
В настоящее время разрабатывается SDK, предназначенный для передачи функций из приложения в YaOS. Это позволит вызывать их по запросу пользователя. Например, при просмотре фильма пользователь хочет перейти на главную страницу или изменить качество изображения, а элементы для перехода в эти разделы не представлены на текущем экране.
Важно
API находится в активной разработке и может меняться. Ниже представлен обзор реализации технологии AppFunctions.
Доступ к SDK будет предоставлен после одобрения заявки на бета-тестирование.
Технические требования
- compileSdk: версия 36 или выше
- Kotlin: версия 2.0 или выше
- Android Gradle plugin: версия 8.9.1 или выше
- KSP (Kotlin Symbol Processing): должен быть подключен и корректно настроен
Как добавить SDK в проект
Файл SDK поставляется по запросу после одобрения заявки на бета-тестирование. Он представляет собой .aar
файл и не содержит внешних зависимостей, поэтому их нужно добавить в проект вручную.
Чтобы реализовать AppFunctions в приложении, необходимо подключить файл SDK к проекту, библиотеку androidx.appfunctions:appfunctions
, а также другие внешние зависимости, необходимые для работы SDK.
-
Поместите
.aar
файл библиотеки в папкуlibs
, которая должна находиться на одном уровне с папкойsrc
внутри вашего проекта. -
В
build.gradle
добавьте следующие зависимости:dependencies { implementation(fileTree("libs")) ksp("androidx.appfunctions:appfunctions-compiler:1.0.0-alpha01") implementation("androidx.appfunctions:appfunctions:1.0.0-alpha01") implementation("androidx.appfunctions:appfunctions-service:1.0.0-alpha01") implementation("androidx.appfunctions:appfunctions-compiler:1.0.0-alpha01") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("androidx.annotation:annotation:1.7.0") implementation("androidx.appsearch:appsearch:1.1.0-beta01") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1") implementation("androidx.lifecycle:lifecycle-process:2.5.1") }
Подробнее про библиотеку
androidx.appfunctions
можно почитать здесь.
Как добавить AppFunctions в приложение
Добавьте интерфейсы для функций приложения и реализации этих функций. Ниже можно посмотреть примеры двух приложений с описанием их интерфейсов и функций.
Приложение с функцией видеоплеера
-
Определите интерфейсы
LikeVideoAppFunction
иRateCurrentVideoAppFunction
для функцийlikeCurrentPlayingVideo
иrateCurrentPlayingVideo
соответственно:VideoPlayerFunctions.kt
const val APP_FUNCTION_SCHEMA_CATEGORY_VIDEO_PLAYER: String = "video_player" @AppFunctionSchemaDefinition( name = "Likes the currently playing video.", version = LikeVideoAppFunction.SCHEMA_VERSION, category = APP_FUNCTION_SCHEMA_CATEGORY_VIDEO_PLAYER ) interface LikeVideoAppFunction { suspend fun likeCurrentPlayingVideo( appFunctionContext: AppFunctionContext, ): Boolean companion object { internal const val SCHEMA_VERSION: Int = 1 } } @AppFunctionSchemaDefinition( name = "Rates the currently playing video. The 'rateValue' parameter accepts values from 1 to 10.", version = RateCurrentVideoAppFunction.SCHEMA_VERSION, category = APP_FUNCTION_SCHEMA_CATEGORY_VIDEO_PLAYER ) interface RateCurrentVideoAppFunction { suspend fun rateCurrentPlayingVideo( appFunctionContext: AppFunctionContext, rateValue: Int, ): Boolean companion object { internal const val SCHEMA_VERSION: Int = 1 } }
LikeVideoAppFunction
Ставит лайк текущему видео.
Параметры:
Параметр
Описание
appFunctionContext
AppFunctionContext
Контекст выполнения функции. Предоставляет доступ к системным ресурсам и текущему состоянию приложения.
Возвращаемое значение:
Признак успешного выполнения операции (Boolean):
true
— лайк успешно поставлен,false
— произошла ошибка.RateCurrentVideoAppFunction
Оценивает текущее видео.
Параметры:
Параметр
Описание
appFunctionContext
AppFunctionContext
Контекст выполнения функции. Предоставляет доступ к системным ресурсам и текущему состоянию приложения.
rateValue
Int
Оценка видео. Должна быть целым числом от 1 до 10.
Возвращаемое значение:
Признак успешного выполнения операции (Boolean):
true
— оценка успешно установлена,false
— произошла ошибка. -
Определите класс
VideoPlayerFunctionsImpl
, который реализует функциональность видеоплеера:VideoPlayerFunctionsImpl.kt
class VideoPlayerFunctionsImpl: LikeVideoAppFunction, RateCurrentVideoAppFunction { @AppFunction(isEnabled = false) override suspend fun likeCurrentPlayingVideo(appFunctionContext: AppFunctionContext): Boolean { // ... return true } @AppFunction(isEnabled = false) override suspend fun rateCurrentPlayingVideo(appFunctionContext: AppFunctionContext, rateValue: Int): Boolean { // ... return true } }
Когда система вызовет какую-либо функцию, будет выполнен код из класса
VideoPlayerFunctionsImpl
, который помечен аннотацией@AppFunction
.
Приложение для создания и хранения заметок
Важно
Передача объектов в функции поддерживается только начиная с Android 12. Поскольку устройства на базе YaOS в настоящее время используют Android 11, данный пример приложения пока не работает.
-
Определите интерфейс
ShowNoteAppFunction
для функцииshowNote
, а также интерфейсы для ее параметров:ShowNoteFunction.kt
@AppFunctionSchemaDefinition( name = "Displays the note with the specified ID.", version = ShowNoteAppFunction.SCHEMA_VERSION, category = APP_FUNCTION_SCHEMA_CATEGORY_NOTES ) interface ShowNoteAppFunction< Parameters : ShowNoteAppFunction.Parameters, Response : ShowNoteAppFunction.Response > { suspend fun showNote( appFunctionContext: AppFunctionContext, showNoteParams: Parameters ): Response interface Parameters { val noteId: String } interface Response { val isSuccess: Boolean } companion object { internal const val SCHEMA_VERSION: Int = 1 } }
ShowNoteAppFunction
Отображает заметку с заданными параметрами.
Параметр
Описание
Определяет структуру входных данных для функции
showNote
. Должен реализовывать вложенный интерфейсShowNoteAppFunction.Parameters
.Определяет структуру выходных данных функции
showNote
. Должен реализовывать вложенный интерфейсShowNoteAppFunction.Response
.Parameters
Определяет структуру входных данных для функции
showNote
. Должен реализовывать вложенный интерфейсShowNoteAppFunction.Parameters
.Параметр
Описание
noteId
String
Уникальный идентификатор заметки.
Response
Определяет структуру выходных данных функции
showNote
. Должен реализовывать вложенный интерфейсShowNoteAppFunction.Response
.Параметр
Описание
isSuccess
Boolean
Флаг успешного выполнения операции:
true
— заметка успешно отображена,false
— произошла ошибка при выполнении. -
Определите классы
ShowNoteParameters
иShowNoteResponse
для конкретной реализации параметров интерфейса:ShowNoteParams.kt
@AppFunctionSerializable data class ShowNoteParameters( override val noteId: String ): ShowNoteAppFunction.Parameters @AppFunctionSerializable data class ShowNoteResponse( override val isSuccess: Boolean ): ShowNoteAppFunction.Response
-
Определите класс
NoteFunctionsImpl
, который реализует функциональность отображения заметок в приложении:NoteFunctionsImpl.kt
class NoteFunctionsImpl: ShowNoteAppFunction<ShowNoteParameters, ShowNoteResponse> { @AppFunction(isEnabled = true) override suspend fun showNote( appFunctionContext: AppFunctionContext, showNoteParams: ShowNoteParameters, ): ShowNoteResponse { // ... return ShowNoteResponse(true) } }
Когда система вызовет какую-либо функцию, будет выполнен код из класса
NoteFunctionsImpl
, который помечен аннотацией@AppFunction
.
Как управлять функциями приложения
Чтобы управлять функциями приложения, используйте предоставляемый SDK класс AppFunctionSdk
, с помощью которого можно вызывать следующие методы:
- Управление конкретной функцией
suspend fun enableAppFunction(functionIdentifier: String)
suspend fun disableAppFunction(functionIdentifier: String)
Параметр |
Описание |
|
String Уникальный идентификатор функции (например, |
- Групповое управление по категориям
suspend fun enableAppFunctionsBySchemaCategory(schemaCategory: String)
suspend fun disableAppFunctionsBySchemaCategory(schemaCategory: String)
Параметр |
Описание |
|
String Категория для группы связанных функций (например, |
Примечание
Все идентификаторы функций functionIdentifier
будут доступны в сгенерированном статичном объекте с названием <Название класса с аннотациями @AppFunction>Ids
. Например, для класса с функциями видеоплеера будет сгенерирован следующий объект:
@Generated("androidx.appfunctions.compiler.AppFunctionCompiler")
public object VideoPlayerFunctionsImplIds {
public const val LIKE_CURRENT_PLAYING_VIDEO_ID: String =
"com.yandex.tv.features.appfunctions.sdk.sample.appfunctions.player.VideoPlayerFunctionsImpl#likeCurrentPlayingVideo"
public const val RATE_CURRENT_PLAYING_VIDEO_ID: String =
"com.yandex.tv.features.appfunctions.sdk.sample.appfunctions.player.VideoPlayerFunctionsImpl#rateCurrentPlayingVideo"
}