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

ユーザーが詳細画面の [利用可能] ボタンで識別されるアプリのリンクを選択すると、ACTION_VIEW を処理するアクティビティが起動されます(searchable.xml ファイル内で "android.intent.action.VIEW" android:searchSuggestIntentAction として設定)。

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

検索の動作

Android TV では、ホーム画面とアプリ内から検索を行うことができます。この 2 つの場合、検索結果は異なります。

ホーム画面から検索する

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

TV 検索結果の再生

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

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

テレビの検索結果

アプリから検索する

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

TV アプリ内検索結果

詳細

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

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