Turns an old Pixel 3 into an iPod. A Kotlin/Compose launcher that boots straight into a full-screen click-wheel player and never lets you out.
Kotlin
0
2 commits
updated Sep 28, 2026
Turns a Pixel 3 into an iPod. Not an app you open β the phone boots into it, and there is nothing else on the device.
A Kotlin/Compose home launcher running in device-owner lock task mode: full-screen iPod classic UI with a working touch click wheel, a filesystem scan for MP3s, and no system UI of any kind.
| Feature | How |
|---|---|
| Boots straight into the player | Registered as CATEGORY_HOME + BOOT_COMPLETED receiver |
| No status or nav bar | Device-owner setLockTaskFeatures(LOCK_TASK_FEATURE_NONE) |
| No power menu, no recents | Same lock-task feature mask |
| Wake straight to the player | setKeyguardDisabled(true) + setShowWhenLocked |
| Music keeps playing when asleep | ExoPlayer in a MediaSessionService foreground service |
| Finds MP3s | Recursive walk of shared storage, ID3 tags via MediaMetadataRetriever |
| Play / pause / next / prev | Click wheel zones and the centre button |
| Brightness | Wheel-adjusted, applied per-window (no system permission needed) |
| Light / dark theme | Settings βΊ Theme |
| Volume keys, no Android panel | Keys consumed in onKeyDown; adjustStreamVolume(..., flags = 0) |
| Screen never sleeps | FLAG_KEEP_SCREEN_ON + ScreenDimmer fades to minimum after 45s idle |
| Idle screen | Near-black OLED view with slowly drifting text (power + burn-in) |
MENU
Β·Β·Β·
ββ ( OK ) βΆβΆ
Β·Β·Β·
βΆββ
You need the Pixel 3 plugged in with USB debugging on, and the phone factory reset with no accounts added β device owner cannot be granted otherwise.
# 1. Remove the Google account first (Settings > Passwords & accounts > Remove),
# so Factory Reset Protection cannot lock you out.
# 2. Settings > System > Reset options > Erase all data
# 3. On first boot skip EVERY sign-in. One account is enough to block provisioning.
# 4. Settings > About phone > tap "Build number" 7 times
# 5. Settings > System > Developer options > USB debugging: ON
# 6. Plug in, accept the debugging prompt, then:
./scripts/provision-kiosk.sh
That installs, grants storage, sets device owner, makes the app home, switches the phone to gesture navigation, and reboots into the kiosk.
macOS has no native MTP support and Android File Transfer is discontinued, so the USB "File Transfer" route is a dead end β and in kiosk mode there is no status bar to change the USB mode from anyway. Use adb over the same cable:
./scripts/push-music.sh ~/Music/my-albums # copy a folder to /sdcard/Music
./scripts/clear-music.sh # delete the sine-tone test set
./scripts/clear-music.sh --all # wipe /sdcard/Music entirely
Then on the device: Music βΊ Rescan Library. The row shows the current track count, so it is obvious whether the scan picked anything up.
./scripts/build-install.sh # rebuild and reinstall after a code change
./scripts/exit-kiosk.sh # recovery: drop device owner, restore normal launcher
The whole of Android is still running underneath β the player is one process among ~230 packages. Two things dominate drain, and neither is the app:
| Component | Rough draw |
|---|---|
| OLED at 60%, full interface | ~700β900 mW |
| OLED at minimum, full interface | ~120β200 mW |
| OLED showing the near-black idle screen | ~40β80 mW |
| MP3 decode + wired output | ~80β150 mW |
| Stock background (Play Services, radios) | ~60β160 mW |
| Background with bloat disabled + radios off | ~20β50 mW |
The pack is 2954 mAh at 3.85 V β 11.4 Wh, so with music playing and the screen left
idle that is roughly 30 h as shipped, and roughly 50 h with the idle screen
plus optimise-battery.sh. These are estimates from component figures, not measured
on this device.
Because the panel is OLED, two things follow:
./scripts/optimise-battery.sh # disable apps a music player never uses
./scripts/optimise-battery.sh --radios-off # also wifi / bluetooth / mobile data
./scripts/optimise-battery.sh --aggressive # also Play Services
./scripts/optimise-battery.sh --restore # undo all of it
Nothing is uninstalled β pm disable-user is reversible.
There is no Exit Kiosk menu on the device β it was removed deliberately, because the appliance should not offer a way out to whoever is holding it.
That makes the recovery path below the only one, so it is worth knowing:
adb shell dpm remove-active-adminfails on a production device owner withSecurityException: Attempt to remove non-test admin. Only the owning app can relinquish the role, which it does via an intent.
./scripts/exit-kiosk.sh # leave kiosk until restart, stay device owner
./scripts/exit-kiosk.sh --full # relinquish ownership, restore launcher, uninstall
Which is just:
adb shell am start -n com.gigakiladze.pod/.MainActivity \
-a com.gigakiladze.pod.EXIT_KIOSK --ez clear_owner true
The caveat: this needs the app to start in order to receive the intent. If a
future build ever crashes on launch, there is no way back except a factory reset
(boot to recovery β erase). Keep USB debugging enabled, and test changes on the
emulator (pod_pixel3 AVD) before installing them here.
local.properties is deliberately not committed, so point Gradle at your SDK with
either an ANDROID_HOME environment variable or your own local.properties:
export ANDROID_HOME="$HOME/Library/Android/sdk" # or wherever yours lives
./gradlew :app:assembleDebug
Needs JDK 17. scripts/env.sh defaults JAVA_HOME to the Homebrew path on macOS;
override it if yours differs.
The release build is signed with the debug key, so
assembleReleaseproduces something installable without a keystore. That is fine for a personal appliance and wrong for anything distributed β swap in a real signing config first.
app/src/main/java/com/gigakiladze/pod/
βββ MainActivity.kt the only Activity; also the home launcher
βββ PodApplication.kt process-scoped library + player
βββ PodSettings.kt theme and brightness, persisted
βββ kiosk/
β βββ KioskController.kt lock task, keyguard, immersive, escape hatch
β βββ PodDeviceAdminReceiver.kt
β βββ BootReceiver.kt
βββ media/
β βββ MusicScanner.kt recursive .mp3 walk
β βββ PodPlaybackService.kt ExoPlayer + MediaSession
β βββ PlayerConnection.kt MediaController bridge to Compose
β βββ MusicLibrary.kt scan state, artist/album grouping
β βββ AlbumArt.kt lazy cover decoding with an LRU cache
β βββ Track.kt
βββ ui/
βββ ClickWheel.kt the wheel: gestures + Canvas drawing
βββ PodRoot.kt navigation, wheel event routing, layout
βββ PodComponents.kt header, menus, Now Playing, brightness
βββ PodDestination.kt menu hierarchy and back stack
βββ PodTheme.kt light/dark palettes
Everything below was exercised on a Pixel 3 AVD (Android 12, API 31) before any real hardware was touched:
dpm set-device-owner β mLockTaskModeState=LOCKED, no status or nav barMANAGE_EXTERNAL_STORAGE only applies to processes started after it is granted.
Grant it to a running app and that process keeps its restricted view of storage and
the scanner silently finds nothing. This is why provision-kiosk.sh grants storage
before set-device-owner (which starts the app via the admin broadcast) and
reboots at the end.LOCK_TASK_FEATURE_NONE removes home,
recents, notifications and the power menu, but a back button remains in 2- and
3-button navigation. Switching the phone to gesture navigation is what actually
clears the last strip of system UI.adb shell force-stop does not work on the locked app, so use a reboot (or the
EXIT_KIOSK intent) when you need a clean restart of the process.Kotlin
88.8%
Shell
11.2%
Turns an old Pixel 3 into an iPod. A Kotlin/Compose launcher that boots straight into a full-screen click-wheel player and never lets you out.
Kotlin
0
2 commits
updated Sep 28, 2026
Turns a Pixel 3 into an iPod. Not an app you open β the phone boots into it, and there is nothing else on the device.
A Kotlin/Compose home launcher running in device-owner lock task mode: full-screen iPod classic UI with a working touch click wheel, a filesystem scan for MP3s, and no system UI of any kind.
| Feature | How |
|---|---|
| Boots straight into the player | Registered as CATEGORY_HOME + BOOT_COMPLETED receiver |
| No status or nav bar | Device-owner setLockTaskFeatures(LOCK_TASK_FEATURE_NONE) |
| No power menu, no recents | Same lock-task feature mask |
| Wake straight to the player | setKeyguardDisabled(true) + setShowWhenLocked |
| Music keeps playing when asleep | ExoPlayer in a MediaSessionService foreground service |
| Finds MP3s | Recursive walk of shared storage, ID3 tags via MediaMetadataRetriever |
| Play / pause / next / prev | Click wheel zones and the centre button |
| Brightness | Wheel-adjusted, applied per-window (no system permission needed) |
| Light / dark theme | Settings βΊ Theme |
| Volume keys, no Android panel | Keys consumed in onKeyDown; adjustStreamVolume(..., flags = 0) |
| Screen never sleeps | FLAG_KEEP_SCREEN_ON + ScreenDimmer fades to minimum after 45s idle |
| Idle screen | Near-black OLED view with slowly drifting text (power + burn-in) |
MENU
Β·Β·Β·
ββ ( OK ) βΆβΆ
Β·Β·Β·
βΆββ
You need the Pixel 3 plugged in with USB debugging on, and the phone factory reset with no accounts added β device owner cannot be granted otherwise.
# 1. Remove the Google account first (Settings > Passwords & accounts > Remove),
# so Factory Reset Protection cannot lock you out.
# 2. Settings > System > Reset options > Erase all data
# 3. On first boot skip EVERY sign-in. One account is enough to block provisioning.
# 4. Settings > About phone > tap "Build number" 7 times
# 5. Settings > System > Developer options > USB debugging: ON
# 6. Plug in, accept the debugging prompt, then:
./scripts/provision-kiosk.sh
That installs, grants storage, sets device owner, makes the app home, switches the phone to gesture navigation, and reboots into the kiosk.
macOS has no native MTP support and Android File Transfer is discontinued, so the USB "File Transfer" route is a dead end β and in kiosk mode there is no status bar to change the USB mode from anyway. Use adb over the same cable:
./scripts/push-music.sh ~/Music/my-albums # copy a folder to /sdcard/Music
./scripts/clear-music.sh # delete the sine-tone test set
./scripts/clear-music.sh --all # wipe /sdcard/Music entirely
Then on the device: Music βΊ Rescan Library. The row shows the current track count, so it is obvious whether the scan picked anything up.
./scripts/build-install.sh # rebuild and reinstall after a code change
./scripts/exit-kiosk.sh # recovery: drop device owner, restore normal launcher
The whole of Android is still running underneath β the player is one process among ~230 packages. Two things dominate drain, and neither is the app:
| Component | Rough draw |
|---|---|
| OLED at 60%, full interface | ~700β900 mW |
| OLED at minimum, full interface | ~120β200 mW |
| OLED showing the near-black idle screen | ~40β80 mW |
| MP3 decode + wired output | ~80β150 mW |
| Stock background (Play Services, radios) | ~60β160 mW |
| Background with bloat disabled + radios off | ~20β50 mW |
The pack is 2954 mAh at 3.85 V β 11.4 Wh, so with music playing and the screen left
idle that is roughly 30 h as shipped, and roughly 50 h with the idle screen
plus optimise-battery.sh. These are estimates from component figures, not measured
on this device.
Because the panel is OLED, two things follow:
./scripts/optimise-battery.sh # disable apps a music player never uses
./scripts/optimise-battery.sh --radios-off # also wifi / bluetooth / mobile data
./scripts/optimise-battery.sh --aggressive # also Play Services
./scripts/optimise-battery.sh --restore # undo all of it
Nothing is uninstalled β pm disable-user is reversible.
There is no Exit Kiosk menu on the device β it was removed deliberately, because the appliance should not offer a way out to whoever is holding it.
That makes the recovery path below the only one, so it is worth knowing:
adb shell dpm remove-active-adminfails on a production device owner withSecurityException: Attempt to remove non-test admin. Only the owning app can relinquish the role, which it does via an intent.
./scripts/exit-kiosk.sh # leave kiosk until restart, stay device owner
./scripts/exit-kiosk.sh --full # relinquish ownership, restore launcher, uninstall
Which is just:
adb shell am start -n com.gigakiladze.pod/.MainActivity \
-a com.gigakiladze.pod.EXIT_KIOSK --ez clear_owner true
The caveat: this needs the app to start in order to receive the intent. If a
future build ever crashes on launch, there is no way back except a factory reset
(boot to recovery β erase). Keep USB debugging enabled, and test changes on the
emulator (pod_pixel3 AVD) before installing them here.
local.properties is deliberately not committed, so point Gradle at your SDK with
either an ANDROID_HOME environment variable or your own local.properties:
export ANDROID_HOME="$HOME/Library/Android/sdk" # or wherever yours lives
./gradlew :app:assembleDebug
Needs JDK 17. scripts/env.sh defaults JAVA_HOME to the Homebrew path on macOS;
override it if yours differs.
The release build is signed with the debug key, so
assembleReleaseproduces something installable without a keystore. That is fine for a personal appliance and wrong for anything distributed β swap in a real signing config first.
app/src/main/java/com/gigakiladze/pod/
βββ MainActivity.kt the only Activity; also the home launcher
βββ PodApplication.kt process-scoped library + player
βββ PodSettings.kt theme and brightness, persisted
βββ kiosk/
β βββ KioskController.kt lock task, keyguard, immersive, escape hatch
β βββ PodDeviceAdminReceiver.kt
β βββ BootReceiver.kt
βββ media/
β βββ MusicScanner.kt recursive .mp3 walk
β βββ PodPlaybackService.kt ExoPlayer + MediaSession
β βββ PlayerConnection.kt MediaController bridge to Compose
β βββ MusicLibrary.kt scan state, artist/album grouping
β βββ AlbumArt.kt lazy cover decoding with an LRU cache
β βββ Track.kt
βββ ui/
βββ ClickWheel.kt the wheel: gestures + Canvas drawing
βββ PodRoot.kt navigation, wheel event routing, layout
βββ PodComponents.kt header, menus, Now Playing, brightness
βββ PodDestination.kt menu hierarchy and back stack
βββ PodTheme.kt light/dark palettes
Everything below was exercised on a Pixel 3 AVD (Android 12, API 31) before any real hardware was touched:
dpm set-device-owner β mLockTaskModeState=LOCKED, no status or nav barMANAGE_EXTERNAL_STORAGE only applies to processes started after it is granted.
Grant it to a running app and that process keeps its restricted view of storage and
the scanner silently finds nothing. This is why provision-kiosk.sh grants storage
before set-device-owner (which starts the app via the admin broadcast) and
reboots at the end.LOCK_TASK_FEATURE_NONE removes home,
recents, notifications and the power menu, but a back button remains in 2- and
3-button navigation. Switching the phone to gesture navigation is what actually
clears the last strip of system UI.adb shell force-stop does not work on the locked app, so use a reboot (or the
EXIT_KIOSK intent) when you need a clean restart of the process.Kotlin
88.8%
Shell
11.2%