# Shelf: Replacing the Google TV Launcher With 700 Lines of Kotlin

> My Google TV had 1 GB of RAM and a home screen full of ads. I wrote Shelf, a 285 KB Apple TV style launcher in Kotlin, then fought the Home button: an accessibility redirect first, then removing the stock launcher and setup wizard with pm uninstall --user 0.

- Author: Maulana Sodiqin (https://msdqn.dev)
- Published: Oct 20, 2026
- Updated: Oct 20, 2026
- URL: https://blog.msdqn.dev/blog/shelf-custom-google-tv-launcher
- Tags: android, google-tv, kotlin, launcher, adb, debloat

My TV is a Xiaomi running Google TV on Android 14 with 1 GB of RAM. Out of the box, the home screen was a wall of ads and "recommendations", and after boot only about 360 MB of RAM was left for the apps I actually open. Moving between rows lagged. Most of the screen was content I never asked for.

So I wrote my own launcher. It is called Shelf, it is about 700 lines of Kotlin, the APK is 285 KB, and it uses around 57 MB of RAM. It looks like the Apple TV home screen: a big top shelf with the focused app's banner, a blurred backdrop, and a five-column grid of rounded tiles. Then I removed the stock launcher so the Home button goes straight to Shelf.

This post covers how Shelf works, why setting a default launcher on Google TV is harder than it should be, and what you lose when you finally take the stock launcher out.

## Debloat first

Before writing any code, I cleaned up the TV over ADB. Turn on Developer options (Settings → System → About, click the Android TV OS build seven times), enable USB debugging and network debugging, then connect from your computer:

```sh
adb connect <tv-ip>:5555
adb shell pm list packages > packages-before.txt
```

Save that package list. It is your map back if something goes wrong.

Every removal used `pm uninstall --user 0`. It removes the app for the current user but leaves the APK on the system partition, so `pm install-existing <package>` or a factory reset brings it back. Nothing is deleted for good.

| Removed | What it was |
| --- | --- |
| `com.miui.tv.analytics`, `com.xiaomi.statistic` | Xiaomi tracking and analytics |
| `com.mitv.tvhome.*` (mitvplus, michannel, oemtab, atv) | PatchWall and Mi TV+, the source of the ads and recommendation tabs |
| `com.xm.webcontent` | Xiaomi web content and ads |
| `com.tcl.browser` | A preinstalled browser |
| `com.google.android.webview.beta` | Unused beta WebView, the stable one stays |
| `com.google.android.play.games` | Play Games |
| `com.google.android.feedback`, `com.android.tv.feedbackconsent` | Feedback reporting |
| `com.xiaomi.mimusic2`, `com.mitv.gallery` | Music player and gallery |
| `com.android.printspooler` | Printing, on a TV |

Fifteen packages, all `Success`. I left Play Store, Google services, Chromecast, the keyboard, the OTA updater, and every Live TV and tuner component alone, because I still watch broadcast TV from an antenna. I also set the animation scales to 0.5x:

```sh
adb shell settings put global window_animation_scale 0.5
adb shell settings put global transition_animation_scale 0.5
adb shell settings put global animator_duration_scale 0.5
```

Ads that come over the network are handled at home, not on the TV. The TV gets its DNS from AdGuard Home on a Raspberry Pi, and the MikroTik router forces every port 53 query to it. I also turned off Private DNS on the TV so apps cannot quietly switch to DNS over TLS and get around the filter.

## Why not Projectivy

The usual answer to "Google TV launcher with no ads" is Projectivy Launcher. It is good, and it was my first suggestion. But I wanted something that looks like tvOS, not something I configure until it almost does. And a launcher is a small app: list installed apps, draw a grid, launch an intent. On a 1 GB device, a small app is exactly what you want.

## How Shelf is built

Shelf uses plain Android Views and `RecyclerView`, with no Compose and no Leanback library. Its only dependencies are `androidx.core` and `recyclerview`. Compose would have been nicer to write, but on this hardware the extra runtime and a slower first frame both matter.

| File | Job |
| --- | --- |
| `HomeActivity.kt` | The home screen: top shelf, grid, focus handling |
| `AppRepository.kt` | Finds installed apps, loads banners, stores pinned order |
| `AppAdapter.kt`, `Tile.kt` | Grid tiles and the focus animation |
| `ShelfLayoutManager.kt` | Scroll so the focused row lands in a fixed spot |
| `HomeRedirectService.kt`, `HomeRedirect.kt`, `BootReceiver.kt` | Getting the Home button to open Shelf |

### Becoming a launcher

The manifest declares `HomeActivity` as a `HOME` activity. It is `singleTask`, excluded from recents, and locked to landscape:

```xml
<intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.HOME" />
    <category android:name="android.intent.category.DEFAULT" />
</intent-filter>
```

Since Android 11, an app cannot see other packages unless it says what it is looking for, so the manifest also includes a `<queries>` block for `LEANBACK_LAUNCHER` and `LAUNCHER` intents. Without it, `queryIntentActivities` returns almost nothing.

### Listing apps

`AppRepository` queries both categories. TV apps first, then phone apps, so sideloaded apps without a TV banner still appear. It hides itself, the stock launcher, and the setup wizards:

```kotlin
for (category in CATEGORIES) {
    val query = Intent(Intent.ACTION_MAIN).addCategory(category)
    for (info in pm.queryIntentActivities(query, 0)) {
        val pkg = info.activityInfo.packageName
        if (pkg == context.packageName || pkg in HIDDEN || pkg in entries) continue
        entries[pkg] = entryFor(info, category)
    }
}
```

Each tile shows the app's TV banner when it has one. When it does not, the tile shows the icon on a background tinted with the icon's average color, sampled from an 8×8 bitmap and darkened to 55% so white labels stay readable. Settings is added as a fake entry with a grey gear, like on tvOS.

Pinned apps sit in front, in the order you choose. The rest are sorted alphabetically. Long-press OK, or press the Menu button, on a tile to pin, unpin, move left or right, or open App info. The order is a comma-separated string in `SharedPreferences`, because that is all it needs.

### The top shelf

The top shelf is the big banner at the top that changes as you move along the first row. On Apple TV, apps fill it with content like posters or "continue watching". On Android, that data is only readable by system apps, so Shelf shows the app's banner as large and sharp as it can.

The banner a launcher normally gets is sized for the tile and looks soft at full width. Shelf asks the app's own resources for the highest-density version instead:

```kotlin
pm.getResourcesForApplication(app)
    .getDrawableForDensity(entry.bannerRes, DisplayMetrics.DENSITY_XXXHIGH, null)
```

Banners are 16:9 and the shelf is wider, so the empty space on each side is filled with a blurred copy of the same banner (`RenderEffect.createBlurEffect`, API 31 and up). Behind that, the hero's background color is the average of the banner's left and right edge pixels, so the blur fades into a matching color instead of black. A second, heavier blur of the banner sits behind the whole screen at 70% opacity, so the background takes on the color of whatever is focused.

Two details keep this smooth on a weak CPU:

- **Debounce.** Holding right on the D-pad moves focus through tiles faster than banners can be decoded. The hero update is posted with a 140 ms delay, and every focus change cancels the pending one. Only the tile you stop on gets decoded.
- **Cache.** The last four hero images stay in an `LruCache`, so moving back and forth between neighbors does not decode again. The cache is cleared whenever a package is installed, removed or updated.

Decoding happens on a single background thread. The swap is a 140 ms fade out and a 320 ms fade in.

### Focus and scrolling

The focused tile scales to 1.16× with a small overshoot, lifts with a shadow, shows a shine overlay, and fades its label in underneath. That is most of what makes it feel like tvOS.

Scrolling is the other part. `GridLayoutManager` normally scrolls just enough to keep the focused view on screen, so the grid creeps up a few pixels at a time. `ShelfLayoutManager` overrides `requestChildRectangleOnScreen` so every row lands in a fixed position instead: the first row sits under the top shelf, and every other row snaps to one anchor line near the top.

```kotlin
val row = getPosition(child) / spanCount
val target = if (row == 0) firstRowTop else rowAnchor
val dy = getDecoratedTop(child) - target
if (dy == 0) return false
if (immediate) parent.scrollBy(0, dy) else parent.smoothScrollBy(0, dy, interpolator, SCROLL_MS)
return true
```

When focus leaves the first row, the top shelf fades out and the grid moves up to use the whole screen. Back, or Home while already on Shelf, jumps to the first tile.

## The hard part: the Home button

Building the launcher was the easy part. Getting the Home button to open it was not.

On a phone, you install a launcher and Android asks which one to use. Google TV does not ask. The stock launcher, `com.google.android.apps.tv.launcherx`, is a privileged system app and wins the `HOME` intent no matter what you set as default. Shelf was installed, declared `HOME`, and the Home button still opened Google TV.

### Attempt 1: disable the stock launcher

The obvious fix is to disable or remove the stock launcher, so Shelf is the only `HOME` activity left. I tried `pm disable-user`. Two things happened:

1. Google TV's setup wizard, `com.google.android.tungsten.setupwraith`, took over the Home button with a recovery screen. It also registers for `HOME`, as a fallback for when the launcher is gone.
2. The system restarted on its own, which also dropped my ADB connection.

I turned the launcher back on and the TV went back to normal. This is the reason most guides tell you never to remove the Google TV launcher.

### Attempt 2: an accessibility redirect

Projectivy's "override launcher" feature uses an accessibility service. It watches for the stock home screen to appear and immediately opens itself on top. Shelf does the same in 30 lines:

```kotlin
override fun onAccessibilityEvent(event: AccessibilityEvent) {
    if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
    if (event.packageName != STOCK_LAUNCHER || event.className != STOCK_HOME) return
    openShelf()
}
```

The service config only listens for `typeWindowStateChanged` from the stock launcher package and cannot read window content. It is as narrow as an accessibility service can be. You can turn it on without touching the TV:

```sh
adb shell settings put secure enabled_accessibility_services dev.msdqn.shelf/.HomeRedirectService
adb shell settings put secure accessibility_enabled 1
```

It worked: press Home in any app, land on Shelf. But it had two problems.

The first was a flash. Google TV's home screen would show for a moment before Shelf covered it.

The second only showed up after a reboot. The TV came back up on Google TV, because something during boot had turned the accessibility service off. I never found a log line showing what did it. So Shelf learned to turn it back on itself. It asks for `WRITE_SECURE_SETTINGS`, which a normal app cannot get but ADB can grant once, and the grant survives reboots:

```sh
adb shell pm grant dev.msdqn.shelf android.permission.WRITE_SECURE_SETTINGS
```

With that permission, `HomeRedirect.ensureEnabled()` adds the service back to `enabled_accessibility_services` if it is missing. It runs from a `BOOT_COMPLETED` receiver, after every Shelf update, and every time Shelf resumes. The boot receiver also sets a flag so the service opens Shelf as soon as it connects. After that, reboots landed on Shelf and stayed there when I checked at 5, 10 and 20 seconds.

That works, and it is where most people should stop. I wanted the flash gone.

### Attempt 3: remove the launcher and the wizard together

Attempt 1 failed because the setup wizard was still there to take the `HOME` intent. Removing both leaves Shelf as the only `HOME` activity, so nothing is left to redirect. Before removing the wizard, check that setup has actually finished, because the wizard is what runs it:

```sh
adb shell settings get secure user_setup_complete
# 1
```

Then remove both:

```sh
adb shell pm uninstall --user 0 com.google.android.apps.tv.launcherx
adb shell pm uninstall --user 0 com.google.android.tungsten.setupwraith
```

Now Shelf is the real launcher. Home goes straight to it with no flash. Reboot lands on Shelf. YouTube, Live TV and Netflix open from the grid, and Home brings you back. There were no crashes or ANRs in `logcat`.

To undo it:

```sh
adb shell pm install-existing com.google.android.apps.tv.launcherx
adb shell pm install-existing com.google.android.tungsten.setupwraith
```

A factory reset also restores both.

> [!warning]
> Keep network debugging turned on after you do this. If Shelf crashes or a system update breaks something, ADB from another machine is the only way back that does not involve a factory reset.

The accessibility service stays in the app. It does nothing while `launcherx` is gone, and it takes over again if a system update ever restores the stock launcher.

## What you lose

The Google TV launcher does more than draw a home screen, and some of that goes away with it:

- **Voice search.** On Google TV, the remote's mic button sends results to the stock launcher. I have not tested it since the removal yet, and I expect it does not work.
- **The input panel and notifications.** The quick panel for switching HDMI inputs and the system notification drawer come from the launcher too. I switch to the antenna through the Live TV app, which still works.
- **Storage.** Removing the launcher for user 0 did not free any space. Free storage stayed at 2.2 GB, because the launcher's update is still stored on the system.

If you rely on voice search, stop at attempt 2. The short flash is a small price for keeping Google Assistant.

## Was it worth it

For my TV, yes. The home screen shows the apps I use, in the order I want, with no ads and no rows of content I did not ask for. It is quick on hardware where the stock launcher was not, and it is 285 KB of code I can read in one sitting.

The more useful lesson is about the Home button. On Google TV, the launcher is not a preference you can change. It is the stock launcher plus a setup wizard waiting to replace it, and you have to deal with both. An accessibility redirect is the safe version and keeps everything working. Removing the two packages with `--user 0` is the clean version and costs you voice search. Both are reversible, as long as ADB stays on.
