Tools: I Baked a Flutter App Into a Car OS. Here's What Broke and What Didn't.

Tools: I Baked a Flutter App Into a Car OS. Here's What Broke and What Didn't.

The Scale of the Problem

Writing the Flutter App

Baking It In: Yocto Layers and Recipes

The Lockfile Problem

Getting the Display Working

The Result

Takeaways This came out of preparing for GSoC 2026 with AGL. Automotive Grade Linux runs the infotainment systems in production Mazdas and Subarus. It's backed by most major automakers and compiles entirely from source - kernel, C library, every system tool. I spent five days building an AGL image from scratch, wrote a Flutter app, and baked it into the OS. This is what actually happened. My laptop had neither the compute nor the disk space. I spun up a GCP VM: First attempt failed overnight at 74%: Yocto needs more than 200 GB. I hit a quota limit trying to expand the disk in Asia, deleted the VM, recreated it in us-central1 with 400 GB, and started over. 8 hours later, after 12,145 compilation tasks: AGL 21.90.0. Codename: vimba. A virtual car computer, inside a cloud VM, in Iowa. The app reads /etc/os-release at runtime to display the AGL version, which means the same binary shows Ubuntu values during local development and AGL values on the actual image - no build flags, no conditionals. The relevant field is PRETTY_NAME: Flutter app on local machine. For the image: Levi Ackerman from Attack on Titan. The sound button plays an audio clip I will not describe further. Yocto builds from "layers" - folders that each contribute something to the final image. AGL ships with layers for its core system, demo apps, and Flutter engine support. To add my app, I created meta-agl-prachi: The .bb file (a "recipe") tells Yocto: where to fetch the source, how to build it, where to install it. Mine pointed to my GitHub repo and used inherit flutter-app, a class provided by meta-flutter that handles all the Flutter-specific build logic. Then I added my layer to the build and ran: It finished in minutes. Suspiciously fast, only 5 tasks rerun. Yocto had used cached output from the previous build and skipped my layer entirely. I ran bitbake agl-quiz-app in isolation to see what was actually failing: Three files deep. In common.inc I found it: flutter pub get --enforce-lockfile requires the lockfile to exactly match resolved dependencies. My lockfile was generated with a slightly different Dart SDK version than the build VM. The fix was a single line in my recipe: One line. After two days of debugging. QEMU runs headless by default. To see the AGL UI, I exposed its display over VNC: I opened port 5901 in GCP's firewall and connected with TigerVNC. First connection: the AGL warning screen, rotated 90 degrees. AGL IVI is designed for portrait car dashboards. One more line in weston.ini fixed the backend: Getting my app to render required understanding AGL's Wayland setup. The compositor runs as agl-driver (uid 1001). Root cannot access agl-driver's Wayland socket - not a permissions workaround, just how Wayland works. The socket lives at /run/user/1001/wayland-0 and only the user who started the compositor can connect to it. I found the correct environment by reading the existing service file: Which revealed the exact variables and paths needed. With those: AGL 21.90.0 (vimba). The version string - "Automotive Grade Linux 21.90.0 (vimba)", pulled live from /etc/os-release at runtime. Sound doesn't come through QEMU yet (that requires additional ALSA configuration), but everything else works. The most useful thing I practiced here wasn't Flutter or Yocto syntax. It was following require statements until I found the line actually doing the thing. common.inc wasn't linked from anywhere in the docs. Three files of reading got me there. When something breaks in Yocto, the error names the failed task, and that task is a readable function somewhere in the layer files. Start there and keep reading. The structure itself is simpler than it looks: layers are folders, recipes are config files, classes are reusable logic. The surface area is large but not deep. The full code and Yocto layer are on GitHub: here Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

WARNING: The free space is running low (0.823GB left) ERROR: No new tasks can be executed since the disk space monitor action is "STOPTASKS" WARNING: The free space is running low (0.823GB left) ERROR: No new tasks can be executed since the disk space monitor action is "STOPTASKS" WARNING: The free space is running low (0.823GB left) ERROR: No new tasks can be executed since the disk space monitor action is "STOPTASKS" Tasks Summary: Attempted 12145 tasks of which 0 didn't need to be rerun and all succeeded. Tasks Summary: Attempted 12145 tasks of which 0 didn't need to be rerun and all succeeded. Tasks Summary: Attempted 12145 tasks of which 0 didn't need to be rerun and all succeeded. Automotive Grade Linux 21.90.0 qemux86-64 ttyS0 qemux86-64 login: root root@qemux86-64:~# Automotive Grade Linux 21.90.0 qemux86-64 ttyS0 qemux86-64 login: root root@qemux86-64:~# Automotive Grade Linux 21.90.0 qemux86-64 ttyS0 qemux86-64 login: root root@qemux86-64:~# Future<void> _loadAglVersion() async { final file = File('/etc/os-release'); final contents = await file.readAsString(); final lines = contents.split('\n'); for (final line in lines) { if (line.startsWith('PRETTY_NAME')) { setState(() { _aglVersion = line.split('=')[1].replaceAll('"', ''); }); break; } } } Future<void> _loadAglVersion() async { final file = File('/etc/os-release'); final contents = await file.readAsString(); final lines = contents.split('\n'); for (final line in lines) { if (line.startsWith('PRETTY_NAME')) { setState(() { _aglVersion = line.split('=')[1].replaceAll('"', ''); }); break; } } } Future<void> _loadAglVersion() async { final file = File('/etc/os-release'); final contents = await file.readAsString(); final lines = contents.split('\n'); for (final line in lines) { if (line.startsWith('PRETTY_NAME')) { setState(() { _aglVersion = line.split('=')[1].replaceAll('"', ''); }); break; } } } meta-agl-prachi/ ├── conf/ │ └── layer.conf └── recipes-apps/ └── agl-quiz-app/ ├── agl-quiz-app.bb └── files/ └── agl_quiz_app.desktop meta-agl-prachi/ ├── conf/ │ └── layer.conf └── recipes-apps/ └── agl-quiz-app/ ├── agl-quiz-app.bb └── files/ └── agl_quiz_app.desktop meta-agl-prachi/ ├── conf/ │ └── layer.conf └── recipes-apps/ └── agl-quiz-app/ ├── agl-quiz-app.bb └── files/ └── agl_quiz_app.desktop bitbake agl-ivi-demo-flutter bitbake agl-ivi-demo-flutter bitbake agl-ivi-demo-flutter ERROR: agl-quiz-app-1.0-r0 do_archive_pub_cache: flutter pub get --enforce-lockfile failed: 1 ERROR: agl-quiz-app-1.0-r0 do_archive_pub_cache: flutter pub get --enforce-lockfile failed: 1 ERROR: agl-quiz-app-1.0-r0 do_archive_pub_cache: flutter pub get --enforce-lockfile failed: 1 find ~/AGL/master/external/meta-flutter -name "*.bbclass" # → flutter-app.bbclass cat flutter-app.bbclass # → require conf/include/flutter-app.inc cat flutter-app.inc # → require conf/include/common.inc cat common.inc find ~/AGL/master/external/meta-flutter -name "*.bbclass" # → flutter-app.bbclass cat flutter-app.bbclass # → require conf/include/flutter-app.inc cat flutter-app.inc # → require conf/include/common.inc cat common.inc find ~/AGL/master/external/meta-flutter -name "*.bbclass" # → flutter-app.bbclass cat flutter-app.bbclass # → require conf/include/flutter-app.inc cat flutter-app.inc # → require conf/include/common.inc cat common.inc if d.getVar("PUBSPEC_IGNORE_LOCKFILE") == "1": pubspec_lock = os.path.join(app_root, 'pubspec.lock') if os.path.exists(pubspec_lock): run_command(d, 'rm -rf pubspec.lock', app_root, env) # ...later... run_command(d, 'flutter pub get --enforce-lockfile', app_root, env) if d.getVar("PUBSPEC_IGNORE_LOCKFILE") == "1": pubspec_lock = os.path.join(app_root, 'pubspec.lock') if os.path.exists(pubspec_lock): run_command(d, 'rm -rf pubspec.lock', app_root, env) # ...later... run_command(d, 'flutter pub get --enforce-lockfile', app_root, env) if d.getVar("PUBSPEC_IGNORE_LOCKFILE") == "1": pubspec_lock = os.path.join(app_root, 'pubspec.lock') if os.path.exists(pubspec_lock): run_command(d, 'rm -rf pubspec.lock', app_root, env) # ...later... run_command(d, 'flutter pub get --enforce-lockfile', app_root, env) PUBSPEC_IGNORE_LOCKFILE = "1" PUBSPEC_IGNORE_LOCKFILE = "1" PUBSPEC_IGNORE_LOCKFILE = "1" runqemu qemux86-64 serialstdio slirp qemuparams="-display vnc=:1" runqemu qemux86-64 serialstdio slirp qemuparams="-display vnc=:1" runqemu qemux86-64 serialstdio slirp qemuparams="-display vnc=:1" backend=vnc backend=vnc backend=vnc cat /usr/lib/systemd/system/flutter-ics-homescreen.service cat /usr/lib/systemd/system/flutter-ics-homescreen.service cat /usr/lib/systemd/system/flutter-ics-homescreen.service su agl-driver -s /bin/sh -c ' WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/1001/ LD_PRELOAD=/usr/lib/librive_text.so LIBCAMERA_LOG_LEVELS=*:ERROR flutter-auto -b /usr/share/flutter/agl_quiz_app/3.38.3/release --xdg-shell-app-id agl_quiz_app' su agl-driver -s /bin/sh -c ' WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/1001/ LD_PRELOAD=/usr/lib/librive_text.so LIBCAMERA_LOG_LEVELS=*:ERROR flutter-auto -b /usr/share/flutter/agl_quiz_app/3.38.3/release --xdg-shell-app-id agl_quiz_app' su agl-driver -s /bin/sh -c ' WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/1001/ LD_PRELOAD=/usr/lib/librive_text.so LIBCAMERA_LOG_LEVELS=*:ERROR flutter-auto -b /usr/share/flutter/agl_quiz_app/3.38.3/release --xdg-shell-app-id agl_quiz_app' - Machine: e2-standard-8 (8 vCPUs, 32 GB RAM) - OS: Ubuntu 22.04 LTS - Disk: 200 GB