From Phone to Dashboard: Build an Android Auto App with Flutter

From Phone to Dashboard: Build an Android Auto App with Flutter

FlutterPulse

This article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!🚀

Run a Flutter app on the car head unit display.

Some time ago, I wrote a simple Flutter app for use in my car. It displays the actual (GPS) speed and location — see the top image. I wanted to explore the geocoding and geolocator plugins.

Then I asked myself: Can I make this app use the head unit display in the car through Android Auto? Well, I could—see the bottom image.

This does not mean I managed to make Android Auto another supported platform, like Android, iOS, Linux, etc. The AA screen is built with Templates, which are very different from Flutter widgets. There are many limitations to what you can do technically, and even more to what Google will allow on the Google Play store. But I did manage to use the car display exactly like the phone display.

Then, I made another Flutter app, called AutoGlucose. Some people with diabetes wear a sensor that measures the level of glucose in their blood and sends it, once a minute, to a server. It is always important to monitor the glucose level and take action if it is getting too low — especially if you are driving a car!

I added som code to this app to make it display just the glucose value and the trend arrow on the car display. It may not look like much, but it is still very useful for diabetics.

And this app I managed to get approved by Google for the production track on Google Play! You can find it here.

I learned a lot from developing these two apps, and if you are considering something similar, or just are curious about how I did it, read on. In addition to your Flutter knowledge, you will need some Kotlin skills, but not much, and Gemini is now built-in in Android Studio and is always there to help you.

How do you build an Android Auto app?

There are plenty of Medium articles on this, and you can also read about it on the Android developer site, for example here. I will not repeat that here. Instead, I will focus on how you can combine Flutter with some Kotlin code to display data on the screen in the car.

There are three types of AA apps: Media apps, messaging apps, and templated apps. The latter are built using the Android for Cars App Library and display data using a template — very different from Flutter widgets. Templated apps are in turn classified as Navigation apps, Point-Of-Interest (POI) apps, Internet-of-things (IOT) apps, and Weather apps. Read about the supported app categories here.

AutoGlucose did not fit well into any of these categories. I thought IOT came closest. So I set out to make AutoGlucose an IOT app.

Overall design

The app has two parts:

  1. The Flutter part handles the logon to the server, the fetching of data from the server, and the building of the glucose history graph.
  2. The Kotlin part displays the latest glucose value and the trend on the car display. It runs as a separate Android service.

The two parts communicate using two mechanisms:

  • A Method Channel is used to send a command from Flutter to Kotlin to refresh the car display when there is new data.
  • Shared Preferences are used to make the glucose values received by Flutter available to Kotlin. Read about Communication using Shared Preferences in my Medium article Build a watch app using Flutter.

The CarAppService

Android Auto will show your app as an icon on the home screen:

But when you tap it, it is not the Flutter app that is started! It starts a service defined in the manifest for the app. I added the following to the Flutter-generated AndroidManifest.XML:

<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" />
<service
android:name=".CarHomeService"
android:exported="true"
android:stopWithTask="false">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.car.intent.category.CAR_SERVICE" />
</intent-filter>
</service>

CarHomeService is the name I gave to my service. It must inherit from CarAppService which is provided by the Android for Cars App Library. It must implement several methods, one of which returns a template. Here is a skeleton:

class CarHomeService : CarAppService() {
override fun createHostValidator(): HostValidator
return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
}
override fun onCreateSession(): Session
return CarHomeSession()
}
}

class CarHomeSession : Session() {
override fun onCreateScreen(intent: Intent): Screen {
return CarHomeScreen(carContext)
}
}

class CarHomeScreen(carContext: CarContext) : Screen(carContext) {
override fun onGetTemplate(): Template {
// build a Pane template
val myHeader = Header.Builder()
.setTitle("AutoGlucose")
.setStartHeaderAction(Action.APP_ICON)
.build()
val myPaneTemplate = PaneTemplate.Builder(myPane)
.setHeader(myHeader)
.build()
return myPaneTemplate
}

The terms session, screen, and template are explained here. The templates you can select for an IOT app are listed here. I chose to use the Pane template since it can accommodate both text and an image.

Here is what the AA screen looks like in the first version of the app:

Here is my Kotlin code to produce the pane for this:

        val trendArrow = sharedPref.getLong("flutter." + "autoglucose.trendarrow", 1L) // 1 to 5
val measurementColor = sharedPref.getLong("flutter." + "autoglucose.measurementcolor", 1L) // 1 to 4
val iconList = intArrayOf(
R.drawable.green_0,
R.drawable.green_1,
R.drawable.green_2,
R.drawable.green_3,
R.drawable.green_4,
R.drawable.gold_0,
R.drawable.gold_1,
R.drawable.gold_2,
R.drawable.gold_3,
R.drawable.gold_4,
R.drawable.orange_0,
R.drawable.orange_1,
R.drawable.orange_2,
R.drawable.orange_3,
R.drawable.orange_4,
R.drawable.red_0,
R.drawable.red_1,
R.drawable.red_2,
R.drawable.red_3,
R.drawable.red_4,
)
val theIndex: Long = ((measurementColor - 1) * 5 + 5 - trendArrow).coerceIn(0, iconList.size.toLong() - 1)
val arrow = IconCompat.createWithResource(
getCarContext(),
iconList[theIndex.toInt()]
)
val row_icon = CarIcon.Builder(arrow).build()
val glucose = sharedPref.getString("flutter." + "autoglucose.glucose", "")
val timeread = sharedPref.getString("flutter." + "autoglucose.timeread", "00:00")
var since = minutesSince(timeread!!)
val dataRow = Row.Builder()
.setTitle("Glucose value $glucose")
.addText("$since minutes ago (at $timeread)")
.setImage(row_icon)
.build()
var myPane: Pane
myPane = Pane.Builder()
.addRow(dataRow)
.build()

The iconList contains all possible arrow icons. The values measurementColor and trendArrow (from Flutter) are retrieved from Shared Preferences and then used to select the right icon and build a CarIcon from it. The glucose value and the time it was read (by the sensor) are used to build the two rows.

Updating the Android Auto screen

Every two minutes (using Timer.periodic), the Flutter code updates the Reading object:

class Reading {
String status = "OK"; // "OK" or error message
String user = "Unknown"; // The user's name, as reported by the server
String dateRead = ""; // mm:dd for the last value sent to the server
String timeRead = "00:00";// hh:mm for ditto
bool isMmolPerL = true; // Values can be in mmol/l or mg/dl
String glucose = "0.0"; // Last value received from the server
int measurementColor = 1; // 1=green, 2=yellow, 3=orange, 4=red
int trendArrow = 3; // 1=down, 2= right-down, 3=right, 4=right-up, 5=up
String patientId = ""; // Users id in the server
double targetLow = 4.0; // Low value for target area
double targetHigh = 10.0; // High value for target area
double alarmLow = 3.5; // Value for low glucose alarm
double alarmHigh = 12.5; // Value for high glucose alarm
String startTime = "06:00"; // hh:mm for start of history
String endTime = "18:00"; // hh:mm for end of history
List<double> glucoseHistory = [3,7,5,9,8,4,6,4,7,]; // Values in 5 minute intervals
String historyRead = "18:00"; // hh:mm for the last API call for history
}

The Dart code for this is fairly straightforward and is not shown here.

Then the Android Auto screen must be updated. In Flutter, we use setState() to update the screen. With Kotlin, a call to invalidate() causes AA to re-run the onGetTemplate code. How can we get the service to do this every two minutes?

One way of doing it is setting up a message handler that calls invalidate() when it receives a special message and then sends a new special message with a two-minute delay:

    private val mHandler: Handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
if (msg.what == MSG_INVALIDATE) {
invalidate()
scheduleInvalidate()
}
}
}

private fun scheduleInvalidate() {
val msg = Message()
msg.what = MSG_INVALIDATE
mHandler.sendMessageDelayed(msg, TWO_MINUTES)
}

This technique works, but I did not use it. I wanted to do the invalidate() in sync with the Flutter part, so that new values are shown immediately. Each time the reading object has been updated, I call updateAA():

static const platform = MethodChannel('autoglucose.ndssoft.se');

Future<void> updateAA(Reading reading) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('autoglucose.glucose', reading.glucose);
await prefs.setInt('autoglucose.trendarrow', reading.trendArrow);
await prefs.setInt('autoglucose.measurementcolor', reading.measurementColor);
await prefs.setString('autoglucose.timeread', reading.timeRead);
await platform.invokeMethod<int>('updateAutoScreen');
}

It uses the standard way of calling Kotlin code from Flutter, as described here. The updateAutoScreen method is defined in MainActivity.kt:

class MainActivity: FlutterActivity() {
private var callNo :Int = 0
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL
).setMethodCallHandler { call, result ->
if (call.method == "updateAutoScreen") {
sendBroadcast()
result.success(0)
} else {
result.notImplemented()
}
}
}
private fun sendBroadcast() {
val intent = Intent("se.ndssoft.autoglucose.INVALIDATE")
callNo += 1
intent.putExtra("callNo", callNo)
sendBroadcast(intent)
}
}

This code broadcasts an intent with a special name which is recognized in the service, as shown below. A counter is included in the intent; this is to aid debugging only.

In the CarHomeScreen class, a broadcast receiver is activated:

init {
val filter = IntentFilter("se.ndssoft.autoglucose.INVALIDATE")
registerReceiver(
carContext,
broadcastReceiver,
filter,
ContextCompat.RECEIVER_EXPORTED
)
}

The broadcast receiver calls invalidate() when it receives an intent with the special name:

   val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "se.ndssoft.autoglucose.INVALIDATE") {
var callNo = intent.getIntExtra("callNo", 0)
invalidate()
}
}
}

Now, when updateAA() is called, the service will re-run the onGetTemplate() code and update the screen.

Starting the Flutter activity

As mentioned before, tapping the AutoGlucose icon on the AA screen starts the service, but not the Flutter activity. If the user starts the app on the phone before starting it on AA, no problem. But if the user starts driving without having started the app on the phone, only the last values stored in Shared Preferences will be shown and they will not be updated. I needed a way of starting the Flutter activity from the service.

You can normally start Flutter's MainActivity this way:

        val intent = Intent(carContext, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent : PendingIntent = PendingIntent.getActivity(
carContext,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
pendingIntent.send()

But from Android 10 (API 29), you are not allowed to do this from a service, which you can read about here. Unless you specifically declare that this is OK by including an option with the MODE_BACKGROUND_ACTIVITY_START_ALLOWED flag:

        val options = ActivityOptions.makeBasic()
options.setPendingIntentBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED)
pendingIntent.send(options.toBundle())

The problem is that this option was added in Android 14 (API 34). So users with phones running older Android versions (about a third of my current users) have to start the Flutter app manually on the phone. I wish I could find a way around this. If you have an idea, please use the Respond function (icon after the end of the story).

There are two cases when I want to start Flutter from the service: the case described above, and to do the logon.

To retrieve the glucose data from the server, the user must have logged on with an e-mail and a password. This is done with Flutter.

If the user taps the AA icon but has never logged on, the service will detect this, and show a different screen:

This pane is created as follows:

var subText : String
if (showContinue) subText = "Continue on phone" else subText = "Tap the icon to logon " + "\u2192"
val myLogonAction = Action.Builder().setIcon(CarIcon.ERROR).setOnClickListener(
ParkedOnlyOnClickListener.create({
tryStartMainActiviy()
showContinue = true
invalidate()
})).build()
val logonRow = Row.Builder()
.setTitle("You are not logged on")
.addText(subText)
.addAction(myLogonAction)
.build()
myPane = Pane.Builder()
.addRow(logonRow)
.build()

When the user taps the icon, the second line will change to "Continue on phone". The Flutter activity is started on the phone and will prompt the user to log on.

Note the use of the ParkedOnlyOnClickListener. This forces the user to stop and park the car before doing the logon on the phone. If he taps the logon icon while driving, Android Auto will show an error message.

Adding the history graph

What I have described so far is the app as I finally got it approved by Google Play Support. I also wanted to show the glucose history graph on the AA screen.

The graph is drawn in Flutter on a canvas and then written as a .png file:

  final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
( canvas.drawXXX commands to draw the curve)
final picture = recorder.endRecording();
ui.Image uiImage = await picture.toImage(width.toInt(), height.toInt());
var byteData = await uiImage.toByteData(format: ui.ImageByteFormat.png);
Uint8List? pngBytes = byteData?.buffer.asUint8List();
final directory = await getApplicationDocumentsDirectory();
String path = directory.path;
File file = File('$path/autoglucosetile.png');
file.writeAsBytesSync(pngBytes!, mode: FileMode.write);
return byteData;

This file is shown on the phone with the Image.memory widget. It is also available to the service and can be shown with the Pane template:

val file = File("/data/user/0/se.ndssoft.autoglucose/app_flutter/autoglucosetile.png")
if(file.exists() && logonStatus == "OK") {
var bitmap = BitmapFactory.decodeFile(file.path)
var iconCompat = IconCompat.createWithBitmap(bitmap)
var img = CarIcon.Builder(iconCompat).build()
myPane = Pane.Builder()
.setImage(img)
.addRow(dataRow)
.build()
}

The resulting AA screen looked like this:

However, Google Play Support rejected this:

Issue found: Images on screen

Your app contains elements that display images on the Auto screen. An app may only display a single static image for content context in the background of the consumption screen, such as album art.

The image isn't static — it is updated every 5 minutes. Could this distract the driver in a dangerous way, do you think? I tried to appeal, but had no luck with that.

I also tested to draw the history image on a "Surface" which is similar to a canvas in Flutter. I added some code to CarHomeScreen to enable the onSurfaceAvailable callback:

  class CarHomeScreen(carContext: CarContext) : Screen(carContext), SurfaceCallback {
init {
carContext.getCarService(AppManager::class.java).setSurfaceCallback(this)
}

(build template)

override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
super.onSurfaceAvailable(surfaceContainer)
val mSurface = surfaceContainer.getSurface()
val mCanvas = mSurface!!.lockCanvas(null);
mCanvas!!.drawColor(Color.GRAY)
val paint = Paint()
val file = File("/data/user/0/se.ndssoft.autoglucose/app_flutter/autoglucosetile.png")
if (file.exists()) {
var bm = BitmapFactory.decodeFile(file.path)
if (carContext.isDarkMode) paint.color = Color.GRAY else paint.color = Color.WHITE
mCanvas?.drawRect(mRect!!, paint)
mCanvas?.drawBitmap(bm, null, mRect!!, paint)
}
mSurface!!.unlockCanvasAndPost(mCanvas)
}

This resulted in a bigger image:

But this version was also rejected by Google Play Support.

I had used a similar technique for the first app. But then the file was not created from a canvas. Instead, I wrapped the scaffold body with a RepaintBoundary widget and created a file from this.

GlobalKey boundaryKey = GlobalKey();

return Scaffold(
body: RepaintBoundary(
key: boundaryKey,
child: Row(...

void writeTheFile() async {
RenderRepaintBoundary boundary =
boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
ui.Image image = await boundary.toImage(pixelRatio: 3.0);
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
Uint8List pngBytes = byteData!.buffer.asUint8List();
final directory = await getApplicationDocumentsDirectory();
String path = directory.path;
File file = File('$path/geoapp.png');
file.writeAsBytesSync(pngBytes!, mode: FileMode.write);
}

I displayed this file on a surface, as described above. I also used a template to display the date and elapsed time, overlaying this part of the screen.

Testing and releasing my Android Auto app

I didn't have to be in my car to test my app. Most of the testing was done using an emulator, the Desktop Head Unit. Read here about how to test with the DHU.

I first tested using Run in Android Studio. Then I did a flutter build apk and flutter install apk and tested the release version. A convenient way to see the print and Log output is using adb:

adb shell ps | findstr se.ndssoft.autoglucose will give you the app's PID (the first number)

adb logcat — pid=xxxx will show all new logcat messages related to this app.

Then I wanted to test the app in the car — but Android Auto did not show an icon for my app. Android Auto in the car only displays apps that were installed from Google Play! I had to bring up the Google Play Console and click Create app, then click Test and release, Testing, Internal testing. I did a flutter build appbundle and uploaded the aab file. Apps on the Internal testing track are not subject to review; they are released within an hour. Now I could install the app from Google Play and test it in the car.

When the app worked as it should, I clicked Promote to copy it to the Production track. After adding screenshots for the Google Play listing and answering a ton of questions, my app was reviewed and finally approved.

I did not try to get my first app (the geocoding app) approved for production. But since it is on the (unreviewed) internal testing track, I can use it myself, and so can up to 99 other users if I add their e-mail addresses to the Internal testers list.

Summary and conclusions

Enabling a Flutter app for Android Auto requires hard work and some Kotlin skills. And even if you succeed, your app can be rejected by Google Play Support. But it is not impossible! The AutoGlucose app is in production on Google Play here!

Report Page