Building a Custom Launcher for ChromeOS
#technology
Building a launcher for Android is already unusual because launchers are deeply tied to the operating system itself. They are not just normal apps. They control navigation, wallpapers, multitasking, widgets and the overall feel of the device.
Building one for ChromeOS is even stranger
I recently started adapting Be nice, my Android launcher project to work better on ChromeOS because I use my Chromebook more like a giant Android tablet than a traditional desktop machine. ChromeOS supports Android apps through ARC (Android Runtime for Chrome), which sounds great in theory, but things become complicated the moment you try building something launcher-like.
The first major issue appears immediately: ChromeOS does not allow Android launchers to become true launchers.
On normal Android devices, switching launchers is simple. You request the home role or open the system home settings and let the user select your app as the default launcher.
fun changeDefaultHomeApp() {
val intent = Intent(Settings.ACTION_HOME_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (
context.packageManager.resolveActivity(
intent,
PackageManager.MATCH_DEFAULT_ONLY
) != null
) {
context.startActivity(intent)
}
}
On ChromeOS, this simply does not work.
Instead of becoming the actual home environment, the launcher behaves like a regular Android app running inside a window. Pressing the Everything Button still opens ChromeOS. Swiping home still exits into the native shell. Your launcher never truly replaces the desktop experience. That completely changes the mental model.
The launcher can still launch apps, render widgets, manage layouts and simulate parts of the Android experience, but it never gains the system-level privileges launchers normally expect. And honestly, this affects much more than navigation.
One of the strangest issues involved wallpapers.
On Android phones and tablets, Be nice behaves like a traditional launcher. The theme enables wallpaper rendering behind transparent UI layers:
<style
name="Theme.BeNice"
parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowShowWallpaper">
true
</item>
<item name="android:windowBackground">
@android:color/transparent
</item>
</style>
The Compose UI also keeps the main surface transparent:
Scaffold(
containerColor = ComposeColor.Transparent
)
On phones, this works beautifully because the launcher truly sits on top of the system wallpaper layer.
ChromeOS breaks that assumption entirely.
ARC apps run inside desktop-managed windows and ChromeOS intentionally prevents fully transparent Android windows from behaving normally. Google’s documentation explains why: completely transparent windows would break desktop composition, event handling and window boundaries. To avoid this, ChromeOS draws a semi-transparent layer behind Android windows instead of exposing the real desktop wallpaper directly. That explains why launcher transparency on ChromeOS often looks broken, muddy or simply black.
Developers and users have reported exactly the same behavior across multiple launcher apps. Many Android launchers on ChromeOS show black backgrounds because ARC does not expose wallpaper composition the same way standard Android does. Eventually I stopped fighting the platform.
Instead of pretending ARC behaved like a real launcher environment, I switched to opaque backgrounds on ChromeOS while preserving transparent wallpaper rendering on phones and tablets.
Scaffold(
containerColor =
if (defaultAppsManager.isRunningOnChromeOs()) {
MaterialTheme.colorScheme.background
} else {
ComposeColor.Transparent
}
)
Technically, this solution is less exciting. Visually, though, it is far more honest. Another interesting problem involved launcher detection. Normally, Android launchers can check whether they currently hold the home role using RoleManager.
val roleManager =
context.getSystemService(RoleManager::class.java)
return roleManager?.isRoleHeld(
RoleManager.ROLE_HOME
) == true
But ChromeOS never grants that role to Android launchers.
That meant Be nice permanently believed it was not the launcher, which caused completely wrong behavior in parts of the app.
Originally, when Be nice was not the default launcher, tapping apps launched them in split-screen multitasking mode instead of standard fullscreen mode. That behavior made sense on phones. It made no sense on ChromeOS.
To solve this, I added explicit ChromeOS detection and intentionally forced the app into launcher mode whenever ARC was detected.
private fun detectIsHomeApp(): Boolean {
if (isRunningOnChromeOs()) {
return true
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val roleManager =
context.getSystemService(RoleManager::class.java)
return roleManager?.isRoleHeld(
RoleManager.ROLE_HOME
) == true
} else {
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
}
val resolveInfo =
context.packageManager.resolveActivity(
intent,
PackageManager.MATCH_DEFAULT_ONLY
)
return resolveInfo?.activityInfo?.packageName ==
context.packageName
}
}
Detecting ChromeOS itself turned out to be surprisingly unreliable. The expected Chromebook feature flag was missing on some ARC and ARCVM builds, so relying on a single system feature check failed on real devices.
The final implementation became a combination of multiple feature probes:
fun isRunningOnChromeOs(): Boolean {
val pm = context.packageManager
return pm.hasSystemFeature(
SYSTEM_FEATURE_TYPE_CHROMEBOOK
) ||
pm.hasSystemFeature(
SYSTEM_FEATURE_ARC
) ||
pm.hasSystemFeature(
SYSTEM_FEATURE_ARC_DEVICE_MANAGEMENT
)
}
This was not about perfectly defining ChromeOS philosophically. It was about detecting when the platform diverged enough from standard Android that launcher behavior needed to change. And honestly, that became the recurring theme of the entire project.
ChromeOS constantly sits in an awkward middle ground where Android APIs technically exist, but the surrounding assumptions no longer hold.
-
Android expects fullscreen task-based experiences.
-
ChromeOS expects resizable desktop windows.
-
Android launchers expect wallpaper ownership.
-
ChromeOS owns the desktop shell.
-
Android multitasking assumes task stacks.
-
ChromeOS wraps ARC apps inside desktop-managed windows.
That mismatch creates strange edge cases everywhere. One particularly frustrating bug involved split-screen automation. Be nice originally specialized in launching two apps side-by-side using Android split-screen APIs. On ChromeOS, attempting adjacent launches frequently caused the originating app window to stop rendering entirely and become a black rectangle.
The app technically remained alive. It just stopped drawing. Issues like this are especially frustrating because they sit somewhere between Android rendering, ARC window management, GPU composition and ChromeOS desktop behavior. And unfortunately, launcher-style apps stress exactly those systems more aggressively than normal Android apps.
The strange thing is that despite all these limitations, the experience is still surprisingly close to working. That is what makes launcher development on ChromeOS both fascinating and frustrating at the same time.
You constantly feel like you are 80% of the way toward a genuinely good desktop Android launcher experience before running into another system-level limitation you cannot control. And eventually, you stop trying to replace the shell entirely.
Instead, the goal becomes building launcher-like workflows that coexist with ChromeOS instead of fighting it.
