🔥

多分30分で映せるAndroid AutoのNavigation

2021/12/09に公開

はじめに

この記事はAndroid Advent Calendar 2021の9日目の記事です.

Android Autoはスマートフォンと自動車車両・車載機と接続することで、ドライバーに最適化されたバージョンを提供することが出来る, 自動車向けに提供されている機能・プラットフォームです.
これまではメッセージアプリや音楽プレイヤーの様なメディアアプリ向け機能のみが公開されていましたが, 今年はナビゲーションアプリ向け機能もサードパーティへ公開され, 今後が期待されるカテゴリーのひとつだと思います.

本稿ではAndroid Autoのナビゲーション向け提供機能の特徴のひとつである地図を出力するための機能を利用し, 簡単なUIを出力するまでを扱っていきます.

環境

OS Android Studio Device
macOS Catalina Arctic Fox / 2020.3.1 Patch 3 Pixel6 Pro/Android 12

ざっくり全体の流れ

ざっくりとした流れは以下のようになります.

  1. テスト環境の用意
  2. プロジェクトの用意
  3. Libraryの取り込み
  4. CarAppServiceの生成
  5. AndroidManifest.xmlにCarAppServiceを登録
  6. Car App API levelの登録
  7. Android Autoをサポートしていることを定義
  8. Sessionの生成
  9. Screenの生成
  10. Templateの生成
  11. Surfaceを利用した描画
  12. HostValidatorの生成

テスト環境の用意

自動車向けのプラットフォームと聞くとテスト環境を用意するのも大変そうと思われるかもしれませんが, Android AutoにはDesktop Head Unit (DHU)というテスト環境が用意されています.
テストを行なうには大きくはDHUとスマートフォンのセットアップを行なう必要がありますが, 手順はそう多くなく, 自動車向けプラットフォームのテスト環境としては導入しやすい方だと思っています.

DHU

DHUのセットアップ手順は以下のとおりです. DHUのインストールの時間が掛かるようであれば, バックグラウンドで実行しつつ後続の手順を進めていってもいいと思います.

  1. SDK ManagerでAndroid Auto Desktop Head Unit Emulatorをインストールする
  2. SDK_LOCATION/extras/google/auto/ 配下にファイル群があることを確認する
  3. 2.配下にあるdesktop-head-unitに実行権限を付与する
chmod +x ./desktop-head-unit

スマートフォン

スマートフォンのセットアップ手順は以下のとおりです.

  1. 開発者オプションを有効にする
  2. Android Autoをインストールする (インストール済みの場合は最新のバージョンであるかを確認する)
  3. Android Autoの設定を開きます (OSバージョンによって異なります)
    1. Android 10以降
      1. 端末の設定からアプリ > すべてのアプリを表示 > Android Autoを開く
      2. アプリ内のその他の設定を開く
    2. Android 9以前
      1. Android Autoを起動する
      2. メニューから設定を開く
  4. 3.の画面最下部にあるバージョンをタップして表示を展開する
    1. で展開した箇所を10回タップし, 開発者モードを有効にする
  5. メニューを開きヘッドユニットサーバーを起動を選択する
  6. 以前に接続された車を選択しAndroid Autoに新しい車を追加が有効になっていることを確認する.
  7. PCとスマートフォンをUSB接続する
  8. スマートフォンの画面がロックされていないことを確認し, 以下のADBコマンドを実行しポートフォワードを設定する
adb forward tcp:5277 tcp:5277

テストを実行する

あとはDHUを起動すれば動作を確認出来ます.

プロジェクトの用意

通常のアプリ開発の様にPhone and Tablet向けのAndroidプロジェクトを用意します. minSdkVersionは23以上になります.
新規にプロジェクトを作成する場合はAutomotiveプロジェクトを選ばないように注意しましょう.

また, Android Autoは後述の通り専用のServiceクラスをベースに動作しますが, デフォルトとなるActivityは必要になるのでその点も注意が必要です.

Libraryの取り込み

Android for Cars App Libraryを取り込みます. 執筆の時点で最新のstableは1.0.0になっています.

build.gradle
dependencies {
    implementation "androidx.car.app:app:1.0.0"
}

CarAppServiceの生成

ここまでで開発を進める準備が整いました.以降ではいよいよ実際にコードに触れていきます.
Android AutoはActivityではなく, 専用のCarAppServiceというServiceクラスを起点に動作するため, 先ずはこのクラスを用意します.

NavigationSampleService
class NavigationSampleService : CarAppService() {

    override fun createHostValidator(): HostValidator {
	TODO()
    }

    override fun onCreateSession(): Session {
	TODO()
    }

}

AndroidManifest.xmlにCarAppServiceを登録

前述で作成したCarAppServiceを利用出来る様にAndroidManifest.xmlに登録します.

AndroidManifest.xml
<service
    android:name=".NavigationSampleService"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:exported="true">
    <intent-filter>
        <action android:name="androidx.car.app.CarAppService" />
        <category android:name="androidx.car.app.category.NAVIGATION" />
    </intent-filter>
</service>

まずintent-filterには
actionandroidx.car.app.CarAppServiceを指定しているのと, categorySupported app categoriesで定義されているいずれかを指定する点が抑えどころです. 今回はNavigationを使いたいため, androidx.car.app.category.NAVIGATIONを指定しています.

また, Hostから参照されるためアイコンとラベルの設定が必要な点も抑えておきたい点です.

Car App API levelの登録

Car App Libraryは独自のAPIレベルを定義しており, 利用の際にはAndroidManifest.xmlに最小APIレベルを宣言する必要があります. ここでは1を指定しています.

AndroidManifest.xml
<meta-data
    android:name="androidx.car.app.minCarApiLevel"
    android:value="1"/>

Android Autoをサポートしていることを定義

Android Autoから使える様にAndroid AutoをサポートしていることをAndroidManifest.xmlに定義する必要があるため, 定義を追加します.

AndroidManifest.xml
<meta-data
    android:name="com.google.android.gms.car.application"
    android:resource="@xml/automotive_app_desc"/>
automotive_app_desc.xml
<automotiveApp>
    <uses name="template" />
</automotiveApp>

Sessionの生成

次にSessionを用意します. SessionクラスではScreenという画面を扱うクラスを生成して返却する必要があります.
また本稿では言及しませんが, CarAppService, Session, そしてScreenライフサイクルを持っていますので, 本格的に作り込んでいく際には理解が不可欠です.

class NavigationSampleService : CarAppService() {

    override fun onCreateSession(): Session {
        return NavigationSampleSession()
    }

}

class NavigationSampleSession : Session() {

    override fun onCreateScreen(intent: Intent): Screen {
	TODO()
    }

}

Screenの生成

Screenを生成します. ScreenではTemplateを返却する必要がありますが, ここでは一旦TODOにして進めていきます.

class NavigationSampleSession : Session() {

    override fun onCreateScreen(intent: Intent): Screen = NavigationSampleScreen(carContext)

}

class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {

    override fun onGetTemplate(): Template {
        TODO()
    }
}

Templateの生成

本稿の肝となる箇所, Templateの生成です. このTemplateは出力するレイアウトや表現によって色々な種類が用意されており, それらを駆使して車載機への画面出力を行ないます. 今回はナビゲーション向けのTemplateであるNavigationTemplateを指定します. NavigationTemplateを指定した場合, 後述するSurfaceCallbackAppManagerに登録することで車載機へ描画出力するためのSurfaceを取得出来る様になり, 地図の様なリッチなUIを出力出来るようになります.
本来であればActionStripを設定することでメニューへ遷移させるトリガーを作ったりも出来るのですが, 今回は仮置としてタイトルの設定だけに留めています.

class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {

    override fun onGetTemplate(): Template = NavigationTemplate.Builder()
        .setActionStrip(
            ActionStrip.Builder()
                .addAction(
                    Action.Builder()
                        .setTitle("TestAction")
                        .build()
                ).build()
        )
        .build()

}

また, NavigationTemplateを利用する場合はAndroidManifest.xmlにandroidx.car.app.NAVIGATION_TEMPLATES権限を付与する必要があるので, こちらも忘れずに定義します.

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.github.nyafunta.navigationsample">

    <uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES"/>

Surfaceを利用した描画

いよいよ画面に出力するUIを描画します. 今回は表示することがポイントなので, Presentationを使ってUIを構築します. Presentationはセカンダリディスプレイへの出力などで用いられるAPIでAPI Lv.17からある機能です. VirtualDisplayと組み合わせることで, 出力先となるSurfaceへ簡単にUIを出力することが出来ます.

先ずはSurfaceCallbackAppManagerに登録し, 車載機へ出力するためのSurfaceを取得します.

NavigationSampleScreen.kt
class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {

    private val surfaceCallback = object : SurfaceCallback {
        override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
            TODO("Not yet implemented")
        }

        override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
            TODO("Not yet implemented")
        }

        override fun onVisibleAreaChanged(visibleArea: Rect) {
        }

        override fun onStableAreaChanged(stableArea: Rect) {
        }

    }

    init {
        carContext.getCarService(AppManager::class.java).setSurfaceCallback(surfaceCallback)
    }

}

CarContextというAndroid Auto向けのContextクラスを経由してAppManagerを取得し, setSurfaceCallbackSurfaceCallbackを登録しています. これによりSurfaceのイベントを受けられるようになり, SurfaceContainer経由でSurfaceを受け取れる様になります.

次に出力処理を担うPresentationです.

NavigationSampleScreen.kt
private lateinit var virtualDisplay: VirtualDisplay

private lateinit var presentation: Presentation

private val surfaceCallback = object : SurfaceCallback {

override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
    setupOrUpdateSurface(surfaceContainer.takeIf { it.surface != null } ?: return)
}

override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
    virtualDisplay.surface = null
}

override fun onVisibleAreaChanged(visibleArea: Rect) {
}

override fun onStableAreaChanged(stableArea: Rect) {
}

private fun setupOrUpdateSurface(surfaceContainer: SurfaceContainer) {
    val surface = surfaceContainer.surface ?: return
    val width = surfaceContainer.width
    val height = surfaceContainer.height
    val dpi = surfaceContainer.dpi

    if (isPresentationInitialized()) {
	virtualDisplay.surface = surface
	virtualDisplay.resize(width, height, dpi)
	return
    }

    setupPresentation(width, height, dpi, surface)
}

private fun isPresentationInitialized(): Boolean =
    this@NavigationSampleScreen::virtualDisplay.isInitialized
	    && this@NavigationSampleScreen::presentation.isInitialized

private fun setupPresentation(width: Int, height: Int, dpi: Int, surface: Surface) {
    virtualDisplay = carContext.getSystemService(DisplayManager::class.java)
	.createVirtualDisplay(
	    "Presentation",
	    width,
	    height,
	    dpi,
	    surface,
	    DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
	)

    presentation = Presentation(carContext, virtualDisplay.display).apply {
	setOnShowListener {
	    val tv = TextView(carContext)
	    tv.text = "This is test projection."
	    tv.gravity = Gravity.CENTER
	    setContentView(tv)
	}
	show()
    }
}

ポイントはPresentationを準備しているsetupPresentationです.

NavigationSampleScreen.kt
private fun setupPresentation(width: Int, height: Int, dpi: Int, surface: Surface) {
    virtualDisplay = carContext.getSystemService(DisplayManager::class.java)
	.createVirtualDisplay(
	    "Presentation",
	    width,
	    height,
	    dpi,
	    surface,
	    DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
	)

    presentation = Presentation(carContext, virtualDisplay.display).apply {
	setOnShowListener {
	    val tv = TextView(carContext)
	    tv.text = "This is test projection."
	    tv.gravity = Gravity.CENTER
	    setContentView(tv)
	}
	show()
    }
}

VirtualDisplaySurfaceContainerから受け取ったSurfaceを渡すことで, VirtualDisplay.displayを渡されたPresentationに設定されたViewが車載機へ出力される様になります.

改めて, 作成したNavigationSampleScreenの全体を以下に載せます.

NavigationSampleScreen.kt
class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {

    private lateinit var virtualDisplay: VirtualDisplay

    private lateinit var presentation: Presentation

    private val surfaceCallback = object : SurfaceCallback {

        override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
            setupOrUpdateSurface(surfaceContainer.takeIf { it.surface != null } ?: return)
        }

        override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
            virtualDisplay.surface = null
        }

        override fun onVisibleAreaChanged(visibleArea: Rect) {
        }

        override fun onStableAreaChanged(stableArea: Rect) {
        }

        private fun setupOrUpdateSurface(surfaceContainer: SurfaceContainer) {
            val surface = surfaceContainer.surface ?: return
            val width = surfaceContainer.width
            val height = surfaceContainer.height
            val dpi = surfaceContainer.dpi

            if (isPresentationInitialized()) {
                virtualDisplay.surface = surface
                virtualDisplay.resize(width, height, dpi)
                return
            }

            setupPresentation(width, height, dpi, surface)
        }

        private fun isPresentationInitialized(): Boolean =
            this@NavigationSampleScreen::virtualDisplay.isInitialized
                    && this@NavigationSampleScreen::presentation.isInitialized

        private fun setupPresentation(width: Int, height: Int, dpi: Int, surface: Surface) {
            virtualDisplay = carContext.getSystemService(DisplayManager::class.java)
                .createVirtualDisplay(
                    "nyafunta",
                    width,
                    height,
                    dpi,
                    surface,
                    DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
                )

            presentation = Presentation(carContext, virtualDisplay.display).apply {
                setOnShowListener {
                    val tv = TextView(carContext)
                    tv.text = "This is test projection."
                    tv.gravity = Gravity.CENTER
                    setContentView(tv)
                }
                show()
            }
        }
    }

    init {
        carContext.getCarService(AppManager::class.java).setSurfaceCallback(surfaceCallback)
    }


    override fun onGetTemplate(): Template = NavigationTemplate.Builder()
        .setActionStrip(
            ActionStrip.Builder()
                .addAction(
                    Action.Builder()
                        .setTitle("TestAction")
                        .build()
                ).build()
        )
        .build()

}

また, Surfaceを利用する場合にはandroidx.car.app.ACCESS_SURFACE権限が必要になるのでこちらも忘れずにAndroidManifest.xmlに追加しておきます.

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.github.nyafunta.navigationsample">

    <uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES"/>
    <uses-permission android:name="androidx.car.app.ACCESS_SURFACE"/>

HostValidatorの生成

最後にHostValidatorを生成し, CarAppServiceへ処理を追記します. ここではドキュメントに記載されているものをそのまま使用します.

NavigationSampleService.kt

class NavigationSampleService : CarAppService() {

    override fun createHostValidator(): HostValidator =
        if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
            HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
        } else {
            HostValidator.Builder(applicationContext)
                .addAllowedHosts(R.array.hosts_allowlist_sample)
                .build()
        }

    override fun onCreateSession(): Session {
        return NavigationSampleSession()
    }

}

いざ実行

アプリをインストールし, DHUを立ち上げてみましょう。 本稿に倣って試されている場合は以下のコマンドだけで実行が出来ます.

$ adb_dhu

以下の画像が表示されれば起動成功です。

まとめ

いかがでしたでしょうか?
今回は車載機へ出力することを目標としたため、その中身はざっくりとしたものとなっていますが思っていたよりは手順少なく動かすに至れたのではないでしょうか?

執筆時点では1.1がbetaとなっており, 今後が楽しみなプラットフォームのひとつかと思います.
これまで車載機向けの開発といったものにあまり関わりのなかった方が, ちょっと触ってみようかなと感じるきっかけとなれますと幸いです. といったところで本稿を締めたいと思います.

最後まで読んでくださりありがとうございました.

参考

Discussion