エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Android Auto向けのアプリを開発してみた

デジスマチームでソフトウェアエンジニアをしている大和です。車で全国各地の温泉を巡るのが趣味です。今回は運転中の困りごとを解決するために開発したAndroid Auto向けアプリの開発過程を通して、開発方法について共有していきます。

この記事はデジスマチームブログリレーの9日目の記事です。

開発のきっかけ

私はAndroid Auto対応のディスプレイがついた車に乗っており、Googleマップのナビゲーションを利用しています。遠出の際には高速道路を運転するのですが、GoogleマップではPA / SAをいい感じに確認する方法がなく、別のサイトでどれくらい先にPA / SAがあるのか確認して設定していました。この手順が毎回面倒なので、今回は自前でアプリを準備してみることにしました。

開発するアプリの内容

今回開発するアプリは以下を満たすものとします。

  • PA / SAを一覧できる
  • 現在地点からの距離を表示できる
  • Googleマップのナビゲーションを設定できる

Android Autoについて

Android Autoとは、Android端末を各社のナビやナビの付いていないディスプレイと接続することで、対応しているアプリを車で利用できるシステムです。対応アプリ自体はスマートフォン側で実行されるのが特徴です。

自動車向けのシステムとしてAndroid Automotive OSというプラットフォームもあり、こちらは車のディスプレイ単体で利用できるものです。しかし私の車はAndroid Auto対応のためこちらは今回触れません。

Android Autoアプリ開発の基礎知識

制限

Android Auto向けのアプリは、運転中に操作されるものも含まれるため制限が含まれます。例えば次のようなものです。

  • 基本的に自由なUIデザインは不可
  • 操作は最小限ステップに制限 (5回まで)

他にもアプリが満たすべき条件が公開されています。

後述する実車テストの際には、一部の例外を除きGoogle Playストアからインストールする必要があるため、予めGoogle Play Consoleデベロッパーアカウントを登録しておく必要があります。

サポートされているアプリカテゴリ

Android Autoでは、運転の安全性を確保するため、開発できるアプリのカテゴリが制限されています。一例として次のようなものがあります。

  • ナビゲーション: 地図アプリなどのルート案内
  • POI (Point of Interest): 駐車場やガソリンスタンドなどのスポット操作
  • メディア: 音楽やラジオ等のオーディオコンテンツ (動画は含まない)
  • コミュニケーション: 通知やメッセージの読み上げ・音声入力

今回のPA / SAを探すアプリは POIカテゴリ に該当します。

Android for Cars アプリライブラリとテンプレート

Android Autoアプリは、Android for Cars アプリライブラリが提供するテンプレートを使って開発することが推奨されています。これにより、開発を簡単にするとともにUIの一貫性と安全性が保たれます。

利用できるテンプレートはカテゴリによって制限されており、例えば地図を表示するテンプレートを利用できるのはナビゲーションカテゴリやPOIカテゴリなどです。

開発環境の設定

Android Studio (Android SDK) の設定

Android Auto開発には、通常のAndroid開発と同様にAndroid Studioを利用します。開発自体はそのまま行えますが、テスト用にDHU (Desktop Head Unit) があると便利です。DHUのインストール手順は次のとおりです。

  1. SDK Managerで SDK Tools タブを開き Android Auto Desktop Head Unit Emulator をインストールする
  2. 実行権限をつける: chmod +x path/to/install/extras/google/auto/desktop-head-unit
    • インストールされるディレクトリは ~/Library/Android/sdk などです

画像付きの手順

DHUを使用することで、Android端末とUSBケーブルがあればAndroid Autoアプリのテストが可能になります。

DHU (Desktop Head Unit) の画面

開発者モードの有効化

通常の開発者モードに加えて、Android Auto用の開発者モードを有効化する必要があります。開発者向けオプションを有効にするおよびAndroid Auto デベロッパーモードを参考にして有効化してください。

また、DHUを使ったテストの際にはデバッグ接続が必要なため、デバイスで USB デバッグを有効にするを参考に設定してください。

テスト方法

DHUでのテスト

通常のAndroidアプリの開発と同じ手順でAndroid Studio上でビルドしてAndroid端末にインストールすることで、DHU上でアプリを実行できます。

DHU上で開発中のアプリを実行している例

DHUはデバッグ接続を有効化したAndroid端末をUSB接続後、次のコマンドを実行することで起動できます。

 path/to/install/extras/google/auto/desktop-head-unit --usb

実車テスト

Google Play Consoleから内部テストとして配信することで、実車でもテスト可能です。内部テストについては一般的なAndroidアプリ開発の手順と同じであるため、詳しい手順はドキュメントを参照してください。

プロジェクト作成とHello World

開発の準備が整ったので、DHUに表示できるところまで整えます。

プロジェクト作成

Android StudioでNew Projectを選択してプロジェクトを作成します。通常のAndroidアプリがAndroid Autoをサポートする形になるので、作成したいアプリに応じてテンプレートを選択します。今回は解説範囲外ですがAndroidアプリとしても動作するように開発するので、Bottom Navigation Views Activityを選択します。

Android StudioのNew Project

Android Auto対応の設定

最初にAndroid Auto対応のためライブラリを追加します。

app/build.gradle.kts :

dependencies {
        :
    implementation("androidx.car.app:app:1.7.0")
    implementation("androidx.car.app:app-projected:1.7.0")
    testImplementation("androidx.car.app:app-testing:1.7.0")
}

次にManifestの設定をします。

app/src/main/AndroidManifest.xml :

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

  <application ...>
      <meta-data
          android:name="com.google.android.gms.car.application"
          android:resource="@xml/automotive_app_desc"/>
      <meta-data android:name="androidx.car.app.minCarApiLevel"
          android:value="1"
          tools:ignore="MetadataTagInsideApplicationTag" />
      <service
          android:name="com.example.aa_example.auto.AACarAppService"
          android:exported="true">
          <intent-filter>
              <action android:name="androidx.car.app.CarAppService" />
              <category android:name="androidx.car.app.category.POI" />
          </intent-filter>
      </service>
      <activity ...>...</activity>
  </application>
</manifest>

app/src/main/res/xml/automotive_app_desc.xml :

<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
  <uses name="template" />
</automotiveApp>

CarAppServiceの実装

AndroidアプリのAndroid Auto対応は、manifestで定義したservice ( CarAppService を継承したclass) を実装することで行えます。ここでは画面に Hello World と表示するアプリを実装します。

前述したテンプレートのうち PlaceListMapTemplate を使って実装します。このテンプレートではスポットのリストを含めることで、地図の横にリストが表示される画面を作成できます。

app/src/main/java/com/example/aa_example/auto/AACarAppService.kt :

package com.example.aa_example.auto

import android.content.Intent
import androidx.car.app.CarAppService
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.validation.HostValidator

class AACarAppService : CarAppService() {
    override fun createHostValidator(): HostValidator {
        return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
    }

    override fun onCreateSession(): Session {
        return object : Session() {
            override fun onCreateScreen(intent: Intent): Screen {
                return AAScreen(carContext)
            }
        }
    }
}

app/src/main/java/com/exmaple/aa_example/auto/AAScreen.kt

package com.example.aa_example.auto

import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.ItemList
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.Template

class AAScreen(carContext: CarContext) : Screen(carContext) {
    override fun onGetTemplate(): Template {
        return PlaceListMapTemplate.Builder()
            .setTitle("Hello World")
            .setHeaderAction(Action.APP_ICON)
            .setItemList(ItemList.Builder().build())
            .build()
    }
}

ここまでの実装をDHUで確認してみると、次のように表示されます。

Hello World

機能実装

DHU上で動作する雛形を準備できたので、欲しい機能を実装します。

権限追加

今回のPOIアプリでは現在地の情報を利用するため、次の権限を追加します。

app/src/main/AndroidManifest.xml :

<manifest ...>
    :
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    :
</manifest>

スポット表示

アプリの機能を実装していきます。まずはPA / SAの情報を持つdata classを準備します。緯度経度を渡すことで直線距離を計算するmethodも実装しておきます。

app/src/main/java/com/example/aa_example/model/PlaceType.kt :

package com.example.aa_example.model                                                                                                                                                               
                                                                                                                                                                                                  
enum class PlaceType(                                                                                                                                                                                 
    val label: String,                                                                                                                                                                               
) {                                                                                                                                                                                                  
    PA("PA"),                                                                                                                                                                                        
    SA("SA"),                                                                                                                                                                                                                                                                                                                                                                      
} 

app/src/main/java/com/example/aa_example/model/PlaceInfo.kt :

package com.example.aa_example.model

import android.location.Location

data class PlaceInfo(
    val type: PlaceType,
    val name: String,
    val latitude: Double,
    val longitude: Double,
) {
    fun distance(currentLat: Double, currentLng: Double): Double {                                                                                                                                                        
        val results = FloatArray(size = 1)                                                                                                                                                                  
        Location.distanceBetween(                                                                                                                                                                    
            currentLat,                                                                                                                                                                        
            currentLng,                                                                                                                                                                       
            latitude,                                                                                                                                                                         
            longitude,                                                                                                                                                                        
            results,                                                                                                                                                                                 
        )                                                                                                                                                                                            
        return results.first() / 1000.0
    }
}

次にPA / SAのデータを準備します。今回はサンプルなのでベタ書きしたファイルを準備します。

app/src/main/java/com/example/aa_example/db/places.kt :

package com.example.aa_example.db

import com.example.aa_example.model.PlaceInfo
import com.example.aa_example.model.PlaceType

val places = listOf(
    PlaceInfo(
        type = PlaceType.SA,
        name = "守谷SA",
        latitude = 35.9397,
        longitude = 139.9685,
    ),
    PlaceInfo(
        type = PlaceType.PA,
        name = "谷田部東PA",
        latitude = 36.0389,
        longitude = 140.1378,
    ),
)

準備ができたので、表示部分を実装していきます。画面の表示は Screen を継承したclassで行われるため、実装を更新して ItemList にPA / SA情報を渡すようにします。

--- a/app/src/main/java/com/example/aa_example/auto/AAScreen.kt
+++ b/app/src/main/java/com/example/aa_example/auto/AAScreen.kt
@@ -1,18 +1,68 @@
 package com.example.aa_example.auto

+import android.text.SpannableString
+import android.text.Spanned
 import androidx.car.app.CarContext
 import androidx.car.app.Screen
 import androidx.car.app.model.Action
+import androidx.car.app.model.CarLocation
+import androidx.car.app.model.Distance
+import androidx.car.app.model.DistanceSpan
 import androidx.car.app.model.ItemList
+import androidx.car.app.model.Metadata
+import androidx.car.app.model.Place
 import androidx.car.app.model.PlaceListMapTemplate
+import androidx.car.app.model.PlaceMarker
+import androidx.car.app.model.Row
 import androidx.car.app.model.Template
+import com.example.aa_example.db.places

 class AAScreen(carContext: CarContext) : Screen(carContext) {
+
+    // フォールバック用の位置情報
+    private val fallbackLocation = 35.6762 to 139.6503
+
+    private fun getCurrentLocation(): Pair<Double, Double> {
+        return fallbackLocation
+    }
+
     override fun onGetTemplate(): Template {
+        val currentLocation = getCurrentLocation()
+
+        val itemListBuilder = ItemList.Builder()
+        places.forEach { place ->
+            val text = SpannableString(" ").apply {
+                val distance = Distance.create(
+                    place.distance(currentLocation.first, currentLocation.second),
+                    Distance.UNIT_KILOMETERS,
+                )
+                setSpan(
+                    DistanceSpan.create(distance),
+                    0,
+                    1,
+                    Spanned.SPAN_INCLUSIVE_INCLUSIVE,
+                )
+            }
+            val row = Row.Builder()
+                .setMetadata(
+                    Metadata.Builder()
+                        .setPlace(
+                            Place.Builder(CarLocation.create(place.latitude, place.longitude))
+                                .setMarker(PlaceMarker.Builder().setLabel(place.type.label).build())
+                                .build()
+                        )
+                        .build()
+                )
+                .setTitle(place.name)
+                .addText(text)
+                .build()
+            itemListBuilder.addItem(row)
+        }
+
         return PlaceListMapTemplate.Builder()
-            .setTitle("Hello World")
+            .setTitle("PA / SA")
             .setHeaderAction(Action.APP_ICON)
-            .setItemList(ItemList.Builder().build())
+            .setItemList(itemListBuilder.build())
             .build()
     }
 }

これによりPA / SA情報がリストで表示されるようになりました。

DHU上でのデバッグの様子

残りの実装をしていきます。まず、現在の位置情報を取得できるようにします。

Screen からアクセス可能な CarContext 経由で様々な情報を取得可能で、位置情報もここから取得します。

--- a/app/src/main/java/com/example/aa_example/auto/AAScreen.kt
+++ b/app/src/main/java/com/example/aa_example/auto/AAScreen.kt
@@ -1,8 +1,11 @@
 package com.example.aa_example.auto

+import android.content.Context
+import android.location.LocationManager
 import android.text.SpannableString
 import android.text.Spanned
 import androidx.car.app.CarContext
+import androidx.car.app.CarToast
 import androidx.car.app.Screen
 import androidx.car.app.model.Action
 import androidx.car.app.model.CarLocation
@@ -19,11 +22,27 @@ import com.example.aa_example.db.places

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

+    private val locationManager: LocationManager = carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+
     // フォールバック用の位置情報
     private val fallbackLocation = 35.6762 to 139.6503

     private fun getCurrentLocation(): Pair<Double, Double> {
-        return fallbackLocation
+        return try {
+            val location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
+            if (location == null) {
+                CarToast.makeText(carContext, "位置情報の取得に失敗しました。", CarToast.LENGTH_SHORT).show()
+                return fallbackLocation
+            }
+
+            location.latitude to location.longitude
+        } catch (e: SecurityException) {
+            CarToast.makeText(carContext, "位置情報の権限がありません。", CarToast.LENGTH_SHORT).show()
+            fallbackLocation
+        } catch (e: Exception) {
+            CarToast.makeText(carContext, "位置情報の取得に失敗しました。", CarToast.LENGTH_SHORT).show()
+            fallbackLocation
+        }
     }

     override fun onGetTemplate(): Template {

最後にリストからGoogleマップへ遷移できるようにします。

--- a/app/src/main/java/com/example/aa_example/auto/AAScreen.kt
+++ b/app/src/main/java/com/example/aa_example/auto/AAScreen.kt
@@ -1,6 +1,7 @@
 package com.example.aa_example.auto

 import android.content.Context
+import android.content.Intent
 import android.location.LocationManager
 import android.text.SpannableString
 import android.text.Spanned
@@ -18,7 +19,9 @@ import androidx.car.app.model.PlaceListMapTemplate
 import androidx.car.app.model.PlaceMarker
 import androidx.car.app.model.Row
 import androidx.car.app.model.Template
+import androidx.core.net.toUri
 import com.example.aa_example.db.places
+import com.example.aa_example.model.PlaceInfo

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

@@ -45,6 +48,18 @@ class AAScreen(carContext: CarContext) : Screen(carContext) {
         }
     }

+    private fun startNavigation(carContext: CarContext, place: PlaceInfo) {
+        try {
+            val uri = "geo:${place.latitude},${place.longitude}?q=${place.name}&mode=d".toUri()
+            val intent = Intent(CarContext.ACTION_NAVIGATE, uri)
+
+            carContext.startCarApp(intent)
+            CarToast.makeText(carContext, "${place.name}への案内を開始します", CarToast.LENGTH_SHORT).show()
+        } catch (e: Exception) {
+            CarToast.makeText(carContext, "地図アプリを開けませんでした", CarToast.LENGTH_SHORT).show()
+        }
+    }
+
     override fun onGetTemplate(): Template {
         val currentLocation = getCurrentLocation()

@@ -74,6 +89,7 @@ class AAScreen(carContext: CarContext) : Screen(carContext) {
                 )
                 .setTitle(place.name)
                 .addText(text)
+                .setOnClickListener { startNavigation(carContext = carContext, place = place) }
                 .build()
             itemListBuilder.addItem(row)
         }

ここまでの実装で欲しい機能を全て実装できました。

完成したアプリと実車テスト

データの追加などの追加実装をした後、実車でテストしてみました。Google Play Consoleで内部テストを有効にしてアプリをインストール後、車のディスプレイに接続して試した結果が次の動画です。

実車テストの様子

また、おまけとしてAndroid上でも通常のアプリとして動作するようにしました。Android上でGoogleマップのナビを設定すると目的地がAndroid Auto側でも引き継がれるので、スマートフォンを操作可能なときはこちらからも目的地を設定できるようになりました。

おまけ

今後の課題

まだ実装できていない点についてメモしておきます。

  • 上り / 下りなどのフィルター実装 → どのようなUIにできるか確認中
  • 高速道路上にいる判定 → 自前で実装する必要がある
  • PA / SAの経路上の距離 → 自前で実装する必要がある
  • リリースの目処 → 2023年11月に基準が厳しくなったので、テスターを集める必要がある

まとめ

Android Auto向けアプリ開発では、通常のAndroid開発とは異なる制約があります。しかし提供されているテンプレートを利用することで、少ない実装でアプリ開発が可能です。私はAndroidアプリ開発経験はありませんでしたが、実装する部分で混乱するところは少なく、実車上でテストするところまでできました。Androidアプリを公開されている方は追加実装することでAndroid Autoに対応できるため、紹介した手順と公式ドキュメントを参考にして実装してみてはいかがでしょうか。

なお、今回紹介した部分のソースコードはGitHub上で公開しています。

We are Hiring!

エムスリーでは一緒にプロダクト開発をするエンジニアを絶賛募集中です。
デジスマの仕事に興味をお持ちいただけたならぜひ詳細をご確認ください。

エンジニア採用ページはこちら

jobs.m3.com

エンジニア新卒採用サイトもできました

fresh.m3recruit.com

カジュアル面談もお待ちしています

jobs.m3.com