TV アプリを検索可能にする

Android TV は、Android 検索インターフェースを使用して、インストール済みのアプリからコンテンツ データを取得し、検索結果をユーザーに配信します。この結果にアプリのコンテンツ データを含めると、ユーザーはアプリ内のコンテンツにすぐにアクセスできます。

アプリは、ユーザーが検索ダイアログに文字を入力したときに検索結果候補を生成するデータ フィールドを Android TV に提供する必要があります。そのためには、候補を提供するコンテンツ プロバイダとともに、Android TV 用のコンテンツ プロバイダおよびその他の重要な情報を含む searchable.xml 構成ファイルをアプリに実装する必要があります。また、ユーザーが検索結果候補を選択したときに起動されるインテントを処理するアクティビティも必要です。これについては、カスタム候補の追加に詳しい説明があります。ここでは、Android TV アプリの主なポイントについて説明します。

このレッスンでは、Android での検索の使用方法に関する知識があることを前提に、Android TV でアプリを検索可能にする方法を示します。このレッスンを学習する前に、Search API ガイドで説明されている概念に精通していることを確認してください。また、検索機能を追加するレッスンもご覧ください。

このレッスンでは、Android TV の GitHub リポジトリにある Android Leanback サンプルアプリのコードの一部について説明します。

列を特定する

SearchManager は、想定されるデータ フィールドを、ローカル データベースの列として表現することにより記述します。データの形式に関係なく、通常はコンテンツ データにアクセスするクラス内で、そのような列にデータ フィールドをマッピングする必要があります。既存データを必須フィールドにマッピングするクラスのビルド方法については、候補テーブルの作成をご覧ください。

SearchManager クラスには、Android TV 用の列がいくつか含まれています。次の表に、重要性が高い列をいくつか示します。

説明
SUGGEST_COLUMN_TEXT_1 コンテンツの名前(必須)
SUGGEST_COLUMN_TEXT_2 コンテンツの説明テキスト
SUGGEST_COLUMN_RESULT_CARD_IMAGE コンテンツの画像 / ポスター / カバー
SUGGEST_COLUMN_CONTENT_TYPE メディアの MIME タイプ
SUGGEST_COLUMN_VIDEO_WIDTH メディアの解像度の幅
SUGGEST_COLUMN_VIDEO_HEIGHT メディアの解像度の高さ
SUGGEST_COLUMN_PRODUCTION_YEAR コンテンツの制作年(必須)
SUGGEST_COLUMN_DURATION メディアのミリ秒単位の再生時間(必須)

検索フレームワークには次の列が必要です。

コンテンツの上記の列の値が Google サーバーによって検出された他のプロバイダからの同じコンテンツの値と一致する場合、システムは、他のプロバイダのアプリへのリンクとともに、コンテンツの詳細ビューでアプリへのディープリンクを提供します。これについては、後述の詳細画面におけるアプリへのディープリンクに詳しい説明があります。

アプリケーションのデータベース クラスでは、次のように列を定義します。

Kotlin

    class VideoDatabase {
        companion object {
            // The columns we'll include in the video database table
            val KEY_NAME = SearchManager.SUGGEST_COLUMN_TEXT_1
            val KEY_DESCRIPTION = SearchManager.SUGGEST_COLUMN_TEXT_2
            val KEY_ICON = SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE
            val KEY_DATA_TYPE = SearchManager.SUGGEST_COLUMN_CONTENT_TYPE
            val KEY_IS_LIVE = SearchManager.SUGGEST_COLUMN_IS_LIVE
            val KEY_VIDEO_WIDTH = SearchManager.SUGGEST_COLUMN_VIDEO_WIDTH
            val KEY_VIDEO_HEIGHT = SearchManager.SUGGEST_COLUMN_VIDEO_HEIGHT
            val KEY_AUDIO_CHANNEL_CONFIG = SearchManager.SUGGEST_COLUMN_AUDIO_CHANNEL_CONFIG
            val KEY_PURCHASE_PRICE = SearchManager.SUGGEST_COLUMN_PURCHASE_PRICE
            val KEY_RENTAL_PRICE = SearchManager.SUGGEST_COLUMN_RENTAL_PRICE
            val KEY_RATING_STYLE = SearchManager.SUGGEST_COLUMN_RATING_STYLE
            val KEY_RATING_SCORE = SearchManager.SUGGEST_COLUMN_RATING_SCORE
            val KEY_PRODUCTION_YEAR = SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR
            val KEY_COLUMN_DURATION = SearchManager.SUGGEST_COLUMN_DURATION
            val KEY_ACTION = SearchManager.SUGGEST_COLUMN_INTENT_ACTION
            ...
        }
        ...
    }
    

Java

    public class VideoDatabase {
        //The columns we'll include in the video database table
        public static final String KEY_NAME = SearchManager.SUGGEST_COLUMN_TEXT_1;
        public static final String KEY_DESCRIPTION = SearchManager.SUGGEST_COLUMN_TEXT_2;
        public static final String KEY_ICON = SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE;
        public static final String KEY_DATA_TYPE = SearchManager.SUGGEST_COLUMN_CONTENT_TYPE;
        public static final String KEY_IS_LIVE = SearchManager.SUGGEST_COLUMN_IS_LIVE;
        public static final String KEY_VIDEO_WIDTH = SearchManager.SUGGEST_COLUMN_VIDEO_WIDTH;
        public static final String KEY_VIDEO_HEIGHT = SearchManager.SUGGEST_COLUMN_VIDEO_HEIGHT;
        public static final String KEY_AUDIO_CHANNEL_CONFIG =
                SearchManager.SUGGEST_COLUMN_AUDIO_CHANNEL_CONFIG;
        public static final String KEY_PURCHASE_PRICE = SearchManager.SUGGEST_COLUMN_PURCHASE_PRICE;
        public static final String KEY_RENTAL_PRICE = SearchManager.SUGGEST_COLUMN_RENTAL_PRICE;
        public static final String KEY_RATING_STYLE = SearchManager.SUGGEST_COLUMN_RATING_STYLE;
        public static final String KEY_RATING_SCORE = SearchManager.SUGGEST_COLUMN_RATING_SCORE;
        public static final String KEY_PRODUCTION_YEAR = SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR;
        public static final String KEY_COLUMN_DURATION = SearchManager.SUGGEST_COLUMN_DURATION;
        public static final String KEY_ACTION = SearchManager.SUGGEST_COLUMN_INTENT_ACTION;
    ...
    

SearchManager 列からデータ フィールドへのマップをビルドする場合、各行に一意の ID を付与するために _ID も指定する必要があります。

Kotlin


    companion object {
        ....

        private fun buildColumnMap(): Map<String, String> {
            return mapOf(
              KEY_NAME to KEY_NAME,
              KEY_DESCRIPTION to KEY_DESCRIPTION,
              KEY_ICON to KEY_ICON,
              KEY_DATA_TYPE to KEY_DATA_TYPE,
              KEY_IS_LIVE to KEY_IS_LIVE,
              KEY_VIDEO_WIDTH to KEY_VIDEO_WIDTH,
              KEY_VIDEO_HEIGHT to KEY_VIDEO_HEIGHT,
              KEY_AUDIO_CHANNEL_CONFIG to KEY_AUDIO_CHANNEL_CONFIG,
              KEY_PURCHASE_PRICE to KEY_PURCHASE_PRICE,
              KEY_RENTAL_PRICE to KEY_RENTAL_PRICE,
              KEY_RATING_STYLE to KEY_RATING_STYLE,
              KEY_RATING_SCORE to KEY_RATING_SCORE,
              KEY_PRODUCTION_YEAR to KEY_PRODUCTION_YEAR,
              KEY_COLUMN_DURATION to KEY_COLUMN_DURATION,
              KEY_ACTION to KEY_ACTION,
              BaseColumns._ID to ("rowid AS " + BaseColumns._ID),
              SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID to ("rowid AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID),
              SearchManager.SUGGEST_COLUMN_SHORTCUT_ID to ("rowid AS " + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID)
            )
        }
    }
    

Java

    ...
      private static HashMap<String, String> buildColumnMap() {
        HashMap<String, String> map = new HashMap<String, String>();
        map.put(KEY_NAME, KEY_NAME);
        map.put(KEY_DESCRIPTION, KEY_DESCRIPTION);
        map.put(KEY_ICON, KEY_ICON);
        map.put(KEY_DATA_TYPE, KEY_DATA_TYPE);
        map.put(KEY_IS_LIVE, KEY_IS_LIVE);
        map.put(KEY_VIDEO_WIDTH, KEY_VIDEO_WIDTH);
        map.put(KEY_VIDEO_HEIGHT, KEY_VIDEO_HEIGHT);
        map.put(KEY_AUDIO_CHANNEL_CONFIG, KEY_AUDIO_CHANNEL_CONFIG);
        map.put(KEY_PURCHASE_PRICE, KEY_PURCHASE_PRICE);
        map.put(KEY_RENTAL_PRICE, KEY_RENTAL_PRICE);
        map.put(KEY_RATING_STYLE, KEY_RATING_STYLE);
        map.put(KEY_RATING_SCORE, KEY_RATING_SCORE);
        map.put(KEY_PRODUCTION_YEAR, KEY_PRODUCTION_YEAR);
        map.put(KEY_COLUMN_DURATION, KEY_COLUMN_DURATION);
        map.put(KEY_ACTION, KEY_ACTION);
        map.put(BaseColumns._ID, "rowid AS " +
                BaseColumns._ID);
        map.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID, "rowid AS " +
                SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
        map.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, "rowid AS " +
                SearchManager.SUGGEST_COLUMN_SHORTCUT_ID);
        return map;
      }
    ...
    

上記の例にある SUGGEST_COLUMN_INTENT_DATA_ID フィールドへのマッピングに注目してください。これは、この行のデータに固有のコンテンツを指す URI の一部、つまり、コンテンツの保存場所を記述する URI の最後の部分です。URI の最初の部分は、テーブル内のすべての行に共通である場合、searchable.xml ファイルで android:searchSuggestIntentData 属性として設定されます。これについては、後述の検索候補を処理するに説明があります。

URI の最初の部分がテーブルの各行で異なる場合は、その値を SUGGEST_COLUMN_INTENT_DATA フィールドにマッピングします。ユーザーがこのコンテンツを選択した場合、起動されるインテントは、SUGGEST_COLUMN_INTENT_DATA_ID と、android:searchSuggestIntentData 属性または SUGGEST_COLUMN_INTENT_DATA フィールドの値のいずれかとの組み合わせから、インテント データを提供します。

検索候補データを提供する

Android TV の検索ダイアログに検索キーワード候補を返すために、コンテンツ プロバイダを実装します。システムは、文字が入力されるたびに query() メソッドを呼び出すことにより、コンテンツ プロバイダに候補をクエリします。query() の実装で、コンテンツ プロバイダは候補データを検索し、候補用に指定した行をポイントする Cursor を返します。

Kotlin

    fun query(uri: Uri, projection: Array<String>, selection: String, selectionArgs: Array<String>,
            sortOrder: String): Cursor {
        // Use the UriMatcher to see what kind of query we have and format the db query accordingly
        when (URI_MATCHER.match(uri)) {
            SEARCH_SUGGEST -> {
                Log.d(TAG, "search suggest: ${selectionArgs[0]} URI: $uri")
                if (selectionArgs == null) {
                    throw IllegalArgumentException(
                            "selectionArgs must be provided for the Uri: $uri")
                }
                return getSuggestions(selectionArgs[0])
            }
            else -> throw IllegalArgumentException("Unknown Uri: $uri")
        }
    }

    private fun getSuggestions(query: String): Cursor {
        val columns = arrayOf<String>(
                BaseColumns._ID,
                VideoDatabase.KEY_NAME,
                VideoDatabase.KEY_DESCRIPTION,
                VideoDatabase.KEY_ICON,
                VideoDatabase.KEY_DATA_TYPE,
                VideoDatabase.KEY_IS_LIVE,
                VideoDatabase.KEY_VIDEO_WIDTH,
                VideoDatabase.KEY_VIDEO_HEIGHT,
                VideoDatabase.KEY_AUDIO_CHANNEL_CONFIG,
                VideoDatabase.KEY_PURCHASE_PRICE,
                VideoDatabase.KEY_RENTAL_PRICE,
                VideoDatabase.KEY_RATING_STYLE,
                VideoDatabase.KEY_RATING_SCORE,
                VideoDatabase.KEY_PRODUCTION_YEAR,
                VideoDatabase.KEY_COLUMN_DURATION,
                VideoDatabase.KEY_ACTION,
                SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID
        )
        return videoDatabase.getWordMatch(query.toLowerCase(), columns)
    }
    

Java

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
            String sortOrder) {
        // Use the UriMatcher to see what kind of query we have and format the db query accordingly
        switch (URI_MATCHER.match(uri)) {
            case SEARCH_SUGGEST:
                Log.d(TAG, "search suggest: " + selectionArgs[0] + " URI: " + uri);
                if (selectionArgs == null) {
                    throw new IllegalArgumentException(
                            "selectionArgs must be provided for the Uri: " + uri);
                }
                return getSuggestions(selectionArgs[0]);
            default:
                throw new IllegalArgumentException("Unknown Uri: " + uri);
        }
    }

    private Cursor getSuggestions(String query) {
        query = query.toLowerCase();
        String[] columns = new String[]{
            BaseColumns._ID,
            VideoDatabase.KEY_NAME,
            VideoDatabase.KEY_DESCRIPTION,
            VideoDatabase.KEY_ICON,
            VideoDatabase.KEY_DATA_TYPE,
            VideoDatabase.KEY_IS_LIVE,
            VideoDatabase.KEY_VIDEO_WIDTH,
            VideoDatabase.KEY_VIDEO_HEIGHT,
            VideoDatabase.KEY_AUDIO_CHANNEL_CONFIG,
            VideoDatabase.KEY_PURCHASE_PRICE,
            VideoDatabase.KEY_RENTAL_PRICE,
            VideoDatabase.KEY_RATING_STYLE,
            VideoDatabase.KEY_RATING_SCORE,
            VideoDatabase.KEY_PRODUCTION_YEAR,
            VideoDatabase.KEY_COLUMN_DURATION,
            VideoDatabase.KEY_ACTION,
            SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID
        };
        return videoDatabase.getWordMatch(query, columns);
    }
    ...
    

マニフェスト ファイルで、コンテンツ プロバイダは特別な扱いを受けます。つまり、アクティビティとしてタグ付けされるのではなく、<provider> として記述されます。プロバイダには、コンテンツ プロバイダの名前空間をシステムに知らせる android:authorities 属性が含まれています。また、android:exported 属性を "true" に設定して、それが返す結果を Android のグローバル検索で使用できるようにする必要があります。

    <provider android:name="com.example.android.tvleanback.VideoContentProvider"
        android:authorities="com.example.android.tvleanback"
        android:exported="true" />
    

検索候補を処理する

アプリには、検索候補設定を構成する res/xml/searchable.xml ファイルを含める必要があります。このファイルには、コンテンツ プロバイダの名前空間をシステムに知らせる android:searchSuggestAuthority 属性が含まれています。この値は、AndroidManifest.xml ファイルの <provider> 要素の android:authorities 属性で指定した文字列値と一致する必要があります。

アプリには、アプリの名前であるラベルを含める必要があります。システムの検索設定では、検索可能なアプリを列挙するときに、このラベルが使用されます。

searchable.xml ファイルは、値 "android.intent.action.VIEW" を含む android:searchSuggestIntentAction もインクルードする必要があります。これにより、カスタム候補を提供するためのインテント アクションが定義されます。これは、検索キーワードを提供するためのインテント アクション(後述)とは異なります。候補のためのインテント アクションを宣言するその他の方法については、インテント アクションの宣言をご覧ください。

アプリは、インテント アクションとともに、android:searchSuggestIntentData 属性で指定するインテント データを提供する必要があります。これは、コンテンツを指す URI の最初の部分です。そのコンテンツのマッピング テーブルのすべての行に共通する URI の部分を記述します。列を特定するで説明されているように、各行に固有の URI の部分は、SUGGEST_COLUMN_INTENT_DATA_ID フィールドで確定されます。候補のためのインテント データを宣言するその他の方法については、インテント データの宣言をご覧ください。

また、query() メソッドの selection パラメータとして渡される値を指定する android:searchSuggestSelection=" ?" 属性では、疑問符(?)の値がクエリテキストに置き換えられることにご注意ください。

最後に、android:includeInGlobalSearch 属性を挿入して、値を "true" に設定します。searchable.xml ファイルの例を次に示します。

    <searchable xmlns:android="http://schemas.android.com/apk/res/android"
        android:label="@string/search_label"
        android:hint="@string/search_hint"
        android:searchSettingsDescription="@string/settings_description"
        android:searchSuggestAuthority="com.example.android.tvleanback"
        android:searchSuggestIntentAction="android.intent.action.VIEW"
        android:searchSuggestIntentData="content://com.example.android.tvleanback/video_database_leanback"
        android:searchSuggestSelection=" ?"
        android:searchSuggestThreshold="1"
        android:includeInGlobalSearch="true">
    </searchable>
    

検索キーワードを処理する

アプリの列のいずれかの値に一致する単語が検索ダイアログに入力されると(前述の列を特定するを参照)、システムは直ちに ACTION_SEARCH インテントを起動します。このインテントを処理するアプリ内のアクティビティは、値に特定の単語を含む列をリポジトリで検索し、それらの列を含むコンテンツ アイテムのリストを返します。AndroidManifest.xml ファイルで、ACTION_SEARCH インテントを処理するアクティビティを次のように指定します。

    ...
      <activity
          android:name="com.example.android.tvleanback.DetailsActivity"
          android:exported="true">

          <!-- Receives the search request. -->
          <intent-filter>
              <action android:name="android.intent.action.SEARCH" />
              <!-- No category needed, because the Intent will specify this class component -->
          </intent-filter>

          <!-- Points to searchable meta data. -->
          <meta-data android:name="android.app.searchable"
              android:resource="@xml/searchable" />
      </activity>
    ...
      <!-- Provides search suggestions for keywords against video meta data. -->
      <provider android:name="com.example.android.tvleanback.VideoContentProvider"
          android:authorities="com.example.android.tvleanback"
          android:exported="true" />
    ...
    

アクティビティでは、searchable.xml ファイルへの参照を含む検索可能な構成も記述する必要があります。グローバル検索ダイアログを使用するには、検索クエリを受信するアクティビティをマニフェストに記述する必要があります。マニフェストには、searchable.xml ファイルに記述されているものとまったく同一の <provider> 要素も記述する必要があります。

詳細画面におけるアプリへのディープリンク

検索候補を処理するの説明に従って検索構成を設定し、列を特定するの説明に従って SUGGEST_COLUMN_TEXT_1SUGGEST_COLUMN_PRODUCTION_YEARSUGGEST_COLUMN_DURATION の各フィールドをマッピングすると、ユーザーが検索結果を選択したときに起動される詳細画面に、コンテンツのウォッチ アクションへのディープリンクが表示されます。図 1 をご覧ください。

詳細画面におけるディープリンク

図 1. Videos by Google(Leanback)サンプルアプリへのディープリンクが表示された詳細画面(Sintel: © copyright Blender Foundation、www.sintel.org)

ユーザーがアプリのリンク(詳細画面の「Available On」ボタンで識別されます)をクリックすると、システムは(searchable.xml ファイルで値 "android.intent.action.VIEW" により android:searchSuggestIntentAction として設定された)ACTION_VIEW を処理するアクティビティを起動します。

アクティビティを起動するカスタム インテントを設定することもできます。その方法は、Android TV の GitHub リポジトリの Android Leanback サンプルアプリに示されています。このサンプルアプリは、選択されたメディアの詳細を表示するために独自の LeanbackDetailsFragmentを起動します。ただし、メディアをすぐに再生するアクティビティを起動して、ユーザーが 1~2 回余計にクリックする手間を省く必要があります。

検索の動作

Android TV では、ホーム画面とアプリ内から検索を実行できます。この 2 つのケースでは、検索結果の表示方法が異なります。

ホーム画面から検索する

ホーム画面から検索すると、最初の結果がエンティティ カードに表示されます。コンテンツを再生できるアプリがあれば、各アプリへのリンクがカードの下部に表示されます。

TV 検索結果の再生

プログラムでエンティティ カードにアプリを配置することはできません。アプリの検索結果が再生オプションとして含まれるためには、コンテンツのタイトル、制作年、再生時間と一致する必要があります。

場合によっては、カードの下にさらに検索結果が表示されます。ユーザーがそれらを表示するには、リモコンを押して下にスクロールする必要があります。各アプリの結果は別々の行に表示されます。行の順序を制御することはできません。ウォッチ アクションをサポートするアプリが最初に表示されます。

TV 検索結果

アプリから検索する

ユーザーは、リモコンまたはゲームパッド コントローラからマイクを起動して、アプリ内から検索を開始できます。検索結果は、アプリのコンテンツの上部に 1 つの行で表示されます。 アプリは、固有のグローバル検索プロバイダを使用して、検索結果を生成します。

TV アプリ内検索結果

詳細

TV アプリの検索の詳細については、検索の概要検索機能を追加するをご覧ください。

SearchFragment を使用してアプリ内検索のエクスペリエンスをカスタマイズする方法については、TV アプリ内で検索するをご覧ください。