Голосовизация приложения (beta)

ТВ приложение может быть интегрировано с голосовым помощником Алисой, что позволяет пользователю управлять интерфейсом с помощью голосовых команд: нажимать на элементы приложения, управлять воспроизведением и вызывать функции приложений (например, поиск).

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

  • Для корректной работы голосового управления приложение должно обязательно поддерживать спецификацию доступности (accessibility).
  • Приложения с проигрываемым контентом (музыка, видео, трансляции) должны поддерживать медиа-сессии.
  • Реализовать продвинутые возможности для управления голосом (поиск, переходы между разделами и т. д.) можно с помощью технологии AppFunctions.

Спецификация доступности (accessibility)

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

Что необходимо реализовать

Из рекомендаций по доступности особенно важно реализовать следующие требования:

  • Пользовательский элемент управления View, на который можно нажимать или переводить на него фокус, должен корректно сообщать об этом службе доступности (Accessibility). Это позволяет голосовому помощнику правильно распознавать и управлять такими элементами.

  • Все кликабельные или фокусируемые изображения и иконки должны иметь заданный contentDescription. Например, иконка поиска в верхнем меню должна иметь contentDescription="Search button" или аналогичное описание. Название фильма или сериала, отображаемое в интерфейсе, должно быть также доступно через contentDescription.

Подробнее о том, как реализовать доступность в приложении, можно узнать в официальных гайдах Google для View, Custom View или Jetpack Compose.

Как проверить

Чтобы проверить текущую разметку в приложении через UIAutomator:

  1. Запустите приложение.

  2. Откройте нужный экран.

  3. Выполните следующую команду:

    adb shell uiautomator dump 
    
  4. Откройте сгенерированный файл /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.

  1. Поместите .aar файл библиотеки в папку libs, которая должна находиться на одном уровне с папкой src внутри вашего проекта.

  2. В 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 в приложение

Добавьте интерфейсы для функций приложения и реализации этих функций. Ниже можно посмотреть примеры двух приложений с описанием их интерфейсов и функций.

Приложение с функцией видеоплеера
  1. Определите интерфейсы 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 — произошла ошибка.

  2. Определите класс 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, данный пример приложения пока не работает.

  1. Определите интерфейс 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

    Отображает заметку с заданными параметрами.

    Параметр

    Описание

    Parameters

    Определяет структуру входных данных для функции showNote. Должен реализовывать вложенный интерфейс ShowNoteAppFunction.Parameters.

    Response

    Определяет структуру выходных данных функции showNote. Должен реализовывать вложенный интерфейс ShowNoteAppFunction.Response.

    Parameters

    Определяет структуру входных данных для функции showNote. Должен реализовывать вложенный интерфейс ShowNoteAppFunction.Parameters.

    Параметр

    Описание

    noteId

    String

    Уникальный идентификатор заметки.

    Response

    Определяет структуру выходных данных функции showNote. Должен реализовывать вложенный интерфейс ShowNoteAppFunction.Response.

    Параметр

    Описание

    isSuccess

    Boolean

    Флаг успешного выполнения операции: true — заметка успешно отображена, false — произошла ошибка при выполнении.

  2. Определите классы ShowNoteParameters и ShowNoteResponse для конкретной реализации параметров интерфейса:

    ShowNoteParams.kt

    @AppFunctionSerializable
    data class ShowNoteParameters(
        override val noteId: String
    ): ShowNoteAppFunction.Parameters
    
    @AppFunctionSerializable
    data class ShowNoteResponse(
        override val isSuccess: Boolean
    ): ShowNoteAppFunction.Response
    
  3. Определите класс 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)

Параметр

Описание

functionIdentifier

String

Уникальный идентификатор функции (например, likeCurrentPlayingVideo).

  • Групповое управление по категориям
suspend fun enableAppFunctionsBySchemaCategory(schemaCategory: String)
suspend fun disableAppFunctionsBySchemaCategory(schemaCategory: String)

Параметр

Описание

schemaCategory

String

Категория для группы связанных функций (например, video_player).

Примечание

Все идентификаторы функций 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"
}