How to Transform My Android Phone Into a Music Box in Kiosk Mode
FleetingI have a nexus 5x phone connected via adb usb with lineage os installed on it.
Let’s remove any possibility to do anything else than using deezer and listen to music.
Flow (cheat sheet)
Initial setup runs end-to-end once. Day-to-day, the only blocks I need:
- Pause on-device : (no laptop): long-press the top-left corner ~3 s, draw the secret 6×6 pattern.
- Pause the kiosk from the host (to change wifi, sign into
Deezer, install updates…) — run the
kiosk-pauseblock. - Re-engage the kiosk (after admin is done) — run
engage-kiosk. Also engaged automatically on boot: MainActivity is the persistent default home, so the framework starts it as part of boot before user space is interactive.
Other rarely-used jumps:
- Undo — fully decommission (demote DO + uninstall kiosk app).
- Wipe the project source tree : — so the next tangle is from-scratch.
Prerequisites
The recipe assumes:
- Developer options enabled, USB debugging enabled, and the host’s RSA fingerprint already accepted on the device.
- root enabled.
Sanity check — the device shows up and we can elevate to root:
adb devices -l | grep "$ANDROID_SERIAL"
adb root
adb shell id
009b02032e6929c5 device usb:1-3.1.3 product:bullhead model:Nexus_5X device:bullhead transport_id:8
restarting adbd as root
uid=0(root) gid=0(root) groups=0(root),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid) context=u:r:su:s0
Install Deezer
Every experiment below runs on a Nexus 5X — arm64-v8a, API 27 (Android 8.1), 2 GB RAM — running LineageOS.
Deezer is not packaged in F-Droid (proprietary). We pin the recipe to a specific old version — Deezer 7.0.22.45 (Feb 2023) from APKMirror — because newer releases are unusable on the Nexus 5X’s 2 GB RAM. We tried:
| Version | Idle PSS | Under-load PSS | Subjective |
|---|---|---|---|
| 9.0.13.5 | 450 MB | forgot to compute | “veeeeery slow” |
| 8.0.50.5 | n/a | 253 MB | “a bit laggy” |
| 7.0.22.45 | 159 MB | 217 MB | “noticeably more responsive” |
(PSS measured via dumpsys meminfo deezer.android.app on the Nexus 5X
under LineageOS 15.1.)
Why not just apkeep? Two reasons:
- APKPure’s
deezer.android.applisting serves the Android-TV variant exclusively (despite the package name). The TV variant gates all playback behind Deezer Premium — no free tier — so it’s unusable even for those who’d otherwise tolerate ads. Confirmed empirically: apkeep download landed us on a “Subscribe to Deezer Premium” wall. - APKMirror, which has both the phone and the TV variants as separate
listings under distinct URL slugs (
deezer-music-podcast-playervsdeezer-music-podcast-player-android-tv), is unreachable via apkeep — they dropped APKMirror in 1.0 because of anti-scraping.
Fetch the APK
Download the .apkm bundle from APKMirror —
→ click BUNDLE variant → DOWNLOAD APK BUNDLE.
Install onto the device
APK=$(ls /tmp/deezer-apk/*.xapk /tmp/deezer-apk/*.apkm /tmp/deezer-apk/*.apk 2>/dev/null | head -1)
clk android adb package install "$APK"
Archive: a.zip
inflating: META-INF/MANIFEST.MF
inflating: META-INF/APKMIRRO.SF
inflating: META-INF/APKMIRRO.RSA
inflating: info.json
inflating: icon.png
inflating: base.apk
inflating: split_config.ar.apk
inflating: split_config.arm64_v8a.apk
inflating: split_config.de.apk
inflating: split_config.en.apk
inflating: split_config.x86.apk
inflating: split_config.es.apk
inflating: split_config.xxhdpi.apk
inflating: split_config.fi.apk
inflating: split_config.xhdpi.apk
inflating: split_config.fr.apk
inflating: split_config.hu.apk
inflating: split_config.it.apk
inflating: split_config.ja.apk
inflating: split_config.nl.apk
inflating: split_config.pl.apk
inflating: split_config.pt.apk
inflating: split_config.tvdpi.apk
inflating: split_config.ru.apk
inflating: split_config.sv.apk
inflating: split_config.tr.apk
inflating: split_config.uk.apk
inflating: split_config.armeabi_v7a.apk
inflating: split_config.xxxhdpi.apk
inflating: split_config.mdpi.apk
inflating: split_config.hdpi.apk
inflating: split_config.ldpi.apk
inflating: split_config.x86_64.apk
extracting: APKM_installer.url
Success
Lock the device to Deezer
Android’s answer to “this device only runs one app” is Lock Task Mode,
set up by a Device Owner admin app via the DevicePolicyManager API.
Once active:
- HOME and recents are suppressed.
- Back can’t leave the locked task at root.
- Status bar is locked (no quick settings, no notifications expansion).
- No “long-press back + overview” escape gesture (that escape exists only for user-initiated screen pinning, not for DPM-initiated lock task).
- A reboot does not lift the lock — our admin re-applies it on boot.
A Device Owner is a single privileged app set via dpm set-device-owner.
Promotion only succeeds on a device with no user accounts and no
secondary users — verified below.
We build the DO admin from source. Off-the-shelf options didn’t deliver on
Android 8.1 (API 27): TestDPC’s “Launch another activity in lock task
mode” is gated to API 28+, and FreeKiosk v1.2.16’s “Enable Lock Mode”
toggle doesn’t actually call startLockTask (its strings reference the
APIs but the wiring is incomplete on this build). The custom app below
uses Activity.startLockTask() — available since API 21 — and a
one-shot ADB install + DO promotion.
Verify the device-owner preconditions
dpm set-device-owner refuses if any accounts exist or there’s already a
DO. Block surfaces the blockers up front.
echo "--- accounts (must be empty) ---"
adb shell -T dumpsys account </dev/null \
| grep -E "^Accounts:|Account \{" || echo "(none)"
echo "--- users (must be only user 0) ---"
adb shell -T pm list users </dev/null
echo "--- existing device owner (must be none) ---"
adb shell -T dumpsys device_policy </dev/null \
| grep -iE "Device Owner" || echo "(none)"
If accounts present: Settings → Accounts → remove. If extra users:
pm remove-user <id>. If a DO is set: use the existing admin’s in-app
“remove device owner” option.
The build environment
The dev shell — JDK 17, Gradle 8, the SDK and a patchelf’d aapt2 — comes
entirely from my android-stack flake (same toolchain as
how to contribute to lemuroid).
build-kiosk runs gradle inside it directly off the remote ref, with
--refresh so each build tracks the flake’s latest. (For local hacking on
the flake itself, point the ref at path:/home/sam/prog/devel/flakes/android
instead.)
Gradle configuration
Four small gradle files. Three at the project root — settings.gradle
(modules + plugin repositories), build.gradle (the AGP plugin classpath),
gradle.properties (AndroidX + JVM args) — plus app/build.gradle, which
configures the app module itself.
settings.gradle:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "deezer-kiosk"
include ':app'
build.gradle:
plugins {
id 'com.android.application' version '8.7.0' apply false
}
gradle.properties:
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
app/build.gradle:
plugins {
id 'com.android.application'
}
android {
namespace 'eu.konubinix.deezerkiosk'
// compileSdk is the compiler's API surface, not what the device runs
// against — targetSdk below (27) is what controls runtime behaviour.
// The only hard floor is AGP 8.7, which refuses compileSdk < 33.
// 34 (rather than 33) just matches the build-tools / platform my
// android-stack flake already ships, so there's nothing extra to add
// there — a convenience alignment, not an external constraint.
compileSdk 34
defaultConfig {
applicationId "eu.konubinix.deezerkiosk"
minSdk 21
targetSdk 27
versionCode 1
versionName "1.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
Declaring the app components
The manifest is a skeleton plus one named block per component, woven together
with noweb (:noweb yes) so each component’s rationale sits right next to its
XML instead of in a list up top.
android:testOnly“true”= on <application> makes the Device Owner removable
via dpm remove-active-admin (the Undo block); the trade-off is that
install then needs adb install -t. The <uses-permission> lines cover
package queries, the overlay window (SYSTEM_ALERT_WINDOW), and the wifi /
bluetooth state reads. The three components fill in below.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<application
android:label="DeezerKiosk"
android:allowBackup="false"
android:testOnly="true">
<!--
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen"
makes MainActivity invisible: when Back from Deezer pops Deezer's
task and falls through to ours, the user doesn't see a blank
frame before onResume re-launches Deezer — they see whatever was
drawn underneath (typically Deezer's last frame still in the
buffer), which masks the flash.
-->
<activity android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<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>
</activity>
<receiver android:name=".KioskAdmin"
android:permission="android.permission.BIND_DEVICE_ADMIN"
android:exported="true">
<meta-data android:name="android.app.device_admin"
android:resource="@xml/device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
<service android:name=".StatusOverlayService"
android:exported="false" />
</application>
</manifest>
MainActivity declares both category.LAUNCHER (visible in All apps) and
category.HOME (eligible to be the system’s home activity). The first time
the kiosk engages (see Engage the lock), it calls
DevicePolicyManager.addPersistentPreferredActivity to make itself the
persistent home — the framework then starts it on boot instead of
Trebuchet, with no chooser the user could deflect to a different launcher.
The translucent theme keeps it invisible when Back falls through from Deezer
(why is in the inline comment).
<!--
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen"
makes MainActivity invisible: when Back from Deezer pops Deezer's
task and falls through to ours, the user doesn't see a blank
frame before onResume re-launches Deezer — they see whatever was
drawn underneath (typically Deezer's last frame still in the
buffer), which masks the flash.
-->
<activity android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<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>
</activity>
KioskAdmin is the DeviceAdminReceiver every Device Owner must declare.
<receiver android:name=".KioskAdmin"
android:permission="android.permission.BIND_DEVICE_ADMIN"
android:exported="true">
<meta-data android:name="android.app.device_admin"
android:resource="@xml/device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
StatusOverlayService draws a battery / wifi / bluetooth readout, since lock
task blanks the real status bar. It needs the SYSTEM_ALERT_WINDOW app-op
(see grant-overlay); the wifi/bt reads use the install-time permissions
declared in the skeleton above.
<service android:name=".StatusOverlayService"
android:exported="false" />
<?xml version="1.0" encoding="utf-8"?>
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies>
<force-lock />
</uses-policies>
</device-admin>
The lock-and-launch activity
MainActivity is the lock-and-launch flow:
- Get
DevicePolicyManager+ our admin component. addPersistentPreferredActivityfor the HOME intent → MainActivity becomes the framework’s persistent home. From the next boot onward, the system starts us in place of Trebuchet, before user space is interactive. No BOOT_COMPLETED-arrival window, no chooser the user could deflect.- Allowlist Deezer + ourselves via
setLockTaskPackages. startLockTask()on our task — enters LOCKED mode (not PINNED) because our package is allowlisted by a real DO.- Launch Deezer via
FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASKso Deezer occupies its own fresh task and becomes foreground. On API 27, that new task is not automatically added tomLockTaskModeTasks(only the calling activity’s task is); only our task is the “anchor” of the locked set. Auto-extension of the locked set to newly-launched allowlisted apps was added in later Android versions. - In
onResume(when user backs out of Deezer’s root, Deezer’s task pops and our task is revealed), re-launch Deezer. Combined with the translucent theme set on this activity inAndroidManifest.xml, the user sees Deezer’s last frame underneath until the relaunch completes — no visible flash.
package eu.konubinix.deezerkiosk;
import android.app.Activity;
import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.Bundle;
import java.util.List;
public class MainActivity extends Activity {
private static final String TARGET = "deezer.android.app";
// Non-persist system property: ADB sets it to "1" to pause the
// kiosk, sets it back to "" to resume. Wiped on reboot, so a reboot
// during a pause still re-engages the kiosk on next boot.
private static final String PAUSED_PROP = "debug.kiosk.paused";
// Set true by StatusOverlayService when the parent completes the hidden
// on-device unlock gesture (long-press the corner hotspot + draw the
// correct pattern). In-memory only, on purpose: a reboot or a process
// kill clears it, so the kiosk re-engages on the next boot / HOME — no
// persisted state to wipe. The engage path (--ez engage true) clears it
// explicitly to re-lock on demand. The app cannot set debug.kiosk.paused
// itself (that property is shell/root-writable only, not from an
// untrusted_app process), which is why the gesture uses its own flag.
static volatile boolean sPausedByGesture = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent launchIntent = getIntent();
// Explicit re-engage clears a prior gesture pause so the lock re-applies
// below. Two triggers: the engage-kiosk block's --ez engage true, and an
// explicit launch from the app drawer (CATEGORY_LAUNCHER) — so tapping the
// DeezerKiosk icon in Trebuchet while paused re-locks on-device, no host
// needed. The boot/HOME start carries CATEGORY_HOME instead, so it never
// clears the flag this way (a reboot re-engages anyway via the fresh
// process, where the static is already false).
boolean explicitEngage = launchIntent != null
&& (launchIntent.getBooleanExtra("engage", false)
|| (launchIntent.getCategories() != null
&& launchIntent.getCategories().contains(Intent.CATEGORY_LAUNCHER)));
if (explicitEngage) {
sPausedByGesture = false;
}
if (isPaused()) {
enterPausedAndHandoff();
return;
}
DevicePolicyManager dpm =
(DevicePolicyManager) getSystemService(DEVICE_POLICY_SERVICE);
ComponentName admin = new ComponentName(this, KioskAdmin.class);
ComponentName self = new ComponentName(this, MainActivity.class);
if (dpm.isDeviceOwnerApp(getPackageName())) {
// Make ourselves the persistent default HOME activity. The
// framework will now start us on boot (in place of Trebuchet)
// before user space is interactive — closing the
// BOOT_COMPLETED escape window. Idempotent: re-calling on
// subsequent launches is a no-op once the preference is set.
IntentFilter homeFilter = new IntentFilter(Intent.ACTION_MAIN);
homeFilter.addCategory(Intent.CATEGORY_HOME);
homeFilter.addCategory(Intent.CATEGORY_DEFAULT);
dpm.addPersistentPreferredActivity(admin, homeFilter, self);
// No PIN/pattern is set on this device — that's a hard requirement
// so the kid can wake the screen with the power button and no auth.
// With no secure credential, the Device Owner may disable the
// keyguard outright, so power-on returns straight to Deezer.
// (setKeyguardDisabled is a no-op the moment a secure lock is set,
// so the recipe keeps screen lock = None — see the config section.)
dpm.setKeyguardDisabled(admin, true);
dpm.setLockTaskPackages(admin,
new String[]{ TARGET, getPackageName() });
startLockTask();
// The real status bar (battery, clock, connectivity) is blanked
// by lock task and there's no API-27 knob to bring it back. Draw
// our own readout instead. See StatusOverlayService.
startService(new Intent(this, StatusOverlayService.class));
}
launchTarget();
}
@Override
protected void onResume() {
super.onResume();
if (isPaused()) {
enterPausedAndHandoff();
return;
}
launchTarget();
}
// Leave the kiosk. stopLockTask() must be called from an Activity (the
// overlay service can't), so the gesture flow funnels through here by
// setting sPausedByGesture and starting MainActivity. It's a no-op in the
// ADB-pause flow (which already ran `am task lock stop`), and it exits lock
// task in the gesture flow because we're the Device Owner's allowlisted
// activity. Then hand HOME off to Trebuchet and finish.
private void enterPausedAndHandoff() {
try { stopLockTask(); } catch (Exception ignored) {}
handoffToFallbackHome();
finish();
}
private void launchTarget() {
Intent intent =
getPackageManager().getLaunchIntentForPackage(TARGET);
if (intent != null) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
}
}
private boolean isPaused() {
return sPausedByGesture || propPaused();
}
private boolean propPaused() {
try {
Class<?> sp = Class.forName("android.os.SystemProperties");
String value = (String) sp.getMethod("get",
String.class, String.class).invoke(null, PAUSED_PROP, "");
return "1".equals(value);
} catch (Exception e) {
return false;
}
}
// While paused we're still the persistent home preference, so HOME
// would loop back to us. Hand off explicitly to whichever OTHER
// HOME-capable activity exists (Trebuchet on LineageOS). Setting the
// component explicitly bypasses our own persistent preference.
private void handoffToFallbackHome() {
// Tear down the status overlay while paused — admin uses Trebuchet.
stopService(new Intent(this, StatusOverlayService.class));
PackageManager pm = getPackageManager();
Intent home = new Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_HOME);
List<ResolveInfo> homes = pm.queryIntentActivities(home, 0);
for (ResolveInfo info : homes) {
if (!getPackageName().equals(info.activityInfo.packageName)) {
Intent launch = new Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_HOME)
.setComponent(new ComponentName(
info.activityInfo.packageName,
info.activityInfo.name))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(launch);
return;
}
}
}
}
package eu.konubinix.deezerkiosk;
import android.app.admin.DeviceAdminReceiver;
public class KioskAdmin extends DeviceAdminReceiver {
}
The secret pattern, as a tiny constants class. This block carries
:exports none, so the literal is excluded from the published HTML — but
org-babel-tangle ignores :exports and still writes Secret.java, so it’s
in the source tree and the built APK (which is fine — only the export is
sensitive). Edit the indices here to set your pattern: index = row*6 + col,
0 = top-left, 35 = bottom-right.
The status overlay
Lock task mode blanks the status bar’s system-info area (battery, clock,
connectivity), and on this build there’s no way to bring it back: the
clean knob — DevicePolicyManager.setLockTaskFeatures with
LOCK_TASK_FEATURE_SYSTEM_INFO — only exists from API 28, and this device
is API 27 (Android 8.1). So instead we put a one-line overlay top-right: the
battery as a label, and for wifi + bluetooth we reuse SystemUI’s own
status-bar icons (its stat_sys_* drawables), tinted white — so they look
exactly like the real status bar. A single receiver repaints on any subsystem
broadcast; values are re-queried on demand rather than cached.
- Battery — from the sticky
ACTION_BATTERY_CHANGED(a null-receiverregisterReceiverhands back the current value synchronously). - Wifi —
ic_qs_wifi_full_4when connected,ic_qs_wifi_no_networkwhen not (state fromConnectivityManager/WifiManager; no location permission, since we don’t read the SSID). We use the quick-settings wifi glyphs, not thestat_sys_wifi_*status icons: those tint via?attr/singleToneColor, which resolves to transparent outside SystemUI’s own status-bar theme, so they’d render invisible. - Bluetooth —
stat_sys_data_bluetooth_connectedwhen an A2DP sink (a speaker / headset) is attached,stat_sys_data_bluetoothotherwise — the adapter stays on, only the audio link matters.
It survives lock task because our package is allowlisted (via
setLockTaskPackages) and overlay windows aren’t part of what lock task
suppresses (HOME, recents, the shade, status-bar expansion, non-allowlisted
activity starts). MainActivity starts the service when it engages the
lock and stops it on pause/handoff. The overlay window needs the
SYSTEM_ALERT_WINDOW app-op (grant-overlay); the wifi/bt reads use the
normal ACCESS_WIFI_STATE / ACCESS_NETWORK_STATE / BLUETOOTH
permissions, granted at install.
package eu.konubinix.deezerkiosk;
import android.app.Service;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothProfile;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.media.AudioManager;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.wifi.WifiManager;
import android.os.BatteryManager;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.SystemClock;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
public class StatusOverlayService extends Service {
private static final String SYSUI = "com.android.systemui";
private WindowManager wm;
private LinearLayout row;
private TextView batteryView;
private ImageView wifiIcon;
private ImageView btIcon;
private WifiManager wifi;
private ConnectivityManager cm;
private BluetoothAdapter bt;
private Context sysuiCtx; // SystemUI's context, for its status icons
// --- hidden on-device unlock gesture ---------------------------------
// The parent pauses the kiosk without ADB: long-press an invisible corner
// hotspot to summon a pattern grid, then draw the secret pattern. We do NOT
// use a fingerprint: enrolling one forces a PIN -> a secure keyguard the kid
// would hit on every wake, and Device-Owner setKeyguardDisabled() is a no-op
// while a PIN is set. An app-level secret needs no system credential, so the
// device stays lock-free and the kid wakes the screen with no auth.
private Handler handler;
private View hotspot; // small touchable corner window (summon)
private PatternView patternView; // full-screen pattern grid (confirm), when shown
// Hold this long on the hotspot to summon the grid. Well above the ~500ms
// system long-press so a kid's stray tap won't trip it.
private static final long LONG_PRESS_MS = 2500;
// Auto-dismiss the grid if abandoned, so a summoned grid never sticks.
private static final long PATTERN_TIMEOUT_MS = 15000;
// Pattern grid is GRID x GRID dots. 6x6 gives a much larger secret space
// than the usual 3x3, so a short pattern is already hard to shoulder-surf.
private static final int GRID = 6;
// The secret, as dot indices: index = row*GRID + col, 0 = top-left,
// GRID*GRID-1 = bottom-right. The literal lives in Secret.java, which is
// tangled from an :exports none block, so it never appears in the published
// HTML — while still being in the source tree and the built APK (fine: only
// the export is sensitive). Edit it in the "the secret pattern" block.
private static final int[] SECRET = Secret.PATTERN;
private final Runnable summon = this::showPattern;
private final Runnable dismiss = this::hidePattern;
// Brute-force throttle. Counters live on the service (not the PatternView,
// which is recreated on every summon), so dismissing and re-summoning the
// grid does NOT reset them — otherwise a kid would just reopen it to retry
// freely. lockUntil is an elapsedRealtime() deadline (monotonic, survives
// re-summon, resets on reboot). Reset on a correct pattern.
private int failCount = 0;
private long lockUntil = 0;
// --- idle auto power-off ---------------------------------------------
// Power the device off after IDLE_MS with no "activity", where activity =
// a user touch OR music playing. lastActivity (elapsedRealtime) is bumped
// by every touch (the hotspot, and ACTION_OUTSIDE from the watcher window
// for taps anywhere on Deezer) and, on each tick, while AudioManager says
// music is active. So a playing playlist keeps it awake, and so does
// browsing without playing; only genuine idle (no touch, no audio) counts.
private AudioManager audioMgr;
private long lastActivity;
private static final long IDLE_MS = 20 * 60 * 1000L; // 20 minutes
private static final long IDLE_TICK_MS = 60 * 1000L; // check every minute
private final Runnable idleTick = this::checkIdle;
private final BroadcastReceiver statusReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context c, Intent i) { render(); }
};
@Override
public void onCreate() {
super.onCreate();
Context app = getApplicationContext();
wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wifi = (WifiManager) app.getSystemService(WIFI_SERVICE);
cm = (ConnectivityManager) app.getSystemService(CONNECTIVITY_SERVICE);
bt = BluetoothAdapter.getDefaultAdapter();
try {
sysuiCtx = createPackageContext(SYSUI, 0);
} catch (PackageManager.NameNotFoundException e) {
sysuiCtx = null;
}
float dp = getResources().getDisplayMetrics().density;
int sz = (int)(18 * dp); // icon box
int sp = (int)(10 * dp); // gap before each icon
row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(16, 6, 16, 6);
// Battery: plain text label.
batteryView = new TextView(this);
batteryView.setTextColor(Color.WHITE);
batteryView.setTextSize(12);
row.addView(batteryView);
wifiIcon = addIcon(sz, sp);
btIcon = addIcon(sz, sp);
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
// TYPE_APPLICATION_OVERLAY: the API-26+ replacement for the
// deprecated TYPE_SYSTEM_ALERT; draws above the app, fine on 27.
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
// Passive overlay over Deezer: never grab focus or touches.
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT);
lp.gravity = Gravity.TOP | Gravity.END;
wm.addView(row, lp);
handler = new Handler(Looper.getMainLooper());
addHotspot();
audioMgr = (AudioManager) getSystemService(AUDIO_SERVICE);
lastActivity = SystemClock.elapsedRealtime();
handler.postDelayed(idleTick, IDLE_TICK_MS);
IntentFilter f = new IntentFilter();
f.addAction(Intent.ACTION_BATTERY_CHANGED);
f.addAction(WifiManager.RSSI_CHANGED_ACTION);
f.addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION);
f.addAction(WifiManager.WIFI_STATE_CHANGED_ACTION);
f.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
f.addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED);
registerReceiver(statusReceiver, f);
// Wifi/bt broadcasts don't fire on register (only battery's sticky
// does), so paint once up front for the initial state.
render();
}
private ImageView addIcon(int size, int leftMargin) {
ImageView v = new ImageView(this);
LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(size, size);
p.leftMargin = leftMargin;
v.setLayoutParams(p);
// Status icons are white masks; force white so SystemUI's theme tint
// (which we can't resolve out here) doesn't leave them black.
v.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
row.addView(v);
return v;
}
// A SystemUI status-bar drawable, by name. getIdentifier is a resource
// lookup (not a hidden Java API), so it's fine on API 27.
private Drawable sysui(String name) {
if (sysuiCtx == null) return null;
Resources r = sysuiCtx.getResources();
int id = r.getIdentifier(name, "drawable", SYSUI);
return id == 0 ? null : r.getDrawable(id, sysuiCtx.getTheme());
}
private void render() {
if (batteryView != null) batteryView.setText(battery());
if (wifiIcon != null)
// The stat_sys_wifi_* icons tint via ?attr/singleToneColor, which
// is transparent outside SystemUI's status-bar theme; the QS wifi
// glyphs are solid white, so they render standalone.
wifiIcon.setImageDrawable(sysui(wifiConnected()
? "ic_qs_wifi_full_4" : "ic_qs_wifi_no_network"));
if (btIcon != null)
btIcon.setImageDrawable(sysui(btConnected()
? "stat_sys_data_bluetooth_connected" : "stat_sys_data_bluetooth"));
}
private String battery() {
Intent i = registerReceiver(null,
new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (i == null) return "";
int level = i.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = i.getIntExtra(BatteryManager.EXTRA_SCALE, 100);
int status = i.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
boolean charging = status == BatteryManager.BATTERY_STATUS_CHARGING
|| status == BatteryManager.BATTERY_STATUS_FULL;
int pct = scale > 0 ? level * 100 / scale : level;
return (charging ? "⚡ " : "") + pct + "%";
}
private boolean wifiConnected() {
if (wifi == null || !wifi.isWifiEnabled()) return false;
NetworkInfo ni = cm != null ? cm.getActiveNetworkInfo() : null;
return ni != null && ni.isConnected()
&& ni.getType() == ConnectivityManager.TYPE_WIFI;
}
private boolean btConnected() {
return bt != null && bt.isEnabled()
&& bt.getProfileConnectionState(BluetoothProfile.A2DP)
== BluetoothProfile.STATE_CONNECTED;
}
// Invisible touchable window in the top-left dead corner (Deezer has no
// controls there). A long-press summons the pattern grid. It's touch-modal
// within its tiny bounds, so it consumes only corner touches; the rest of
// the screen still goes to Deezer. (Verified: a touchable overlay receives
// touches over the lock-task foreground app even when not allowlisted; ours
// is in the allowlisted Device-Owner app, so it's strictly safer.)
private void addHotspot() {
float d = getResources().getDisplayMetrics().density;
int hs = (int)(56 * d);
hotspot = new View(this); // no background: invisible on purpose
hotspot.setOnTouchListener((v, e) -> {
switch (e.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
markActivity();
handler.postDelayed(summon, LONG_PRESS_MS);
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
handler.removeCallbacks(summon);
return true;
case MotionEvent.ACTION_OUTSIDE:
// A touch anywhere outside this corner (i.e. on Deezer). With
// FLAG_WATCH_OUTSIDE_TOUCH we get this as a notification while
// the touch still reaches Deezer — our window-wide activity
// sensor for the idle timer.
markActivity();
return true;
}
return true;
});
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
hs, hs,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT);
lp.gravity = Gravity.TOP | Gravity.START;
wm.addView(hotspot, lp);
}
private void markActivity() {
lastActivity = SystemClock.elapsedRealtime();
}
// Runs every IDLE_TICK_MS. Music playing counts as activity (keeps the box
// awake mid-playlist); a touch already bumped lastActivity directly. Once
// neither has happened for IDLE_MS, power the device off via root.
private void checkIdle() {
if (audioMgr != null && audioMgr.isMusicActive()) markActivity();
if (SystemClock.elapsedRealtime() - lastActivity >= IDLE_MS) {
powerOff();
return; // shutting down; no point rescheduling
}
handler.postDelayed(idleTick, IDLE_TICK_MS);
}
// True power-off (not just screen-off) so an idle box doesn't drain the
// battery. No app/Device-Owner API exists for shutdown, so we use root
// (LineageOS su; the kiosk app is granted once at setup). The kid powers
// back on with the button -> boot -> the kiosk re-engages.
private void powerOff() {
try {
Runtime.getRuntime().exec(new String[]{ "su", "-c", "svc power shutdown" });
} catch (Exception e) {
android.util.Log.e("KioskIdle", "shutdown failed", e);
}
}
private void showPattern() {
if (patternView != null) return; // already up
patternView = new PatternView(this);
patternView.setBackgroundColor(0xC0000000); // dim the screen
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
// Full-screen + touchable: grabs every touch so Deezer gets none
// while the grid is up. NOT_FOCUSABLE: leave Deezer's focus alone.
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT);
wm.addView(patternView, lp);
handler.postDelayed(dismiss, PATTERN_TIMEOUT_MS);
}
private void hidePattern() {
handler.removeCallbacks(dismiss);
if (patternView != null && wm != null) {
wm.removeView(patternView);
patternView = null;
}
}
// Called by PatternView on touch-up with the dots drawn, in order.
private void onPatternComplete(List<Integer> seq) {
if (seq.isEmpty()) { hidePattern(); return; } // tap on empty area = cancel
if (matches(seq, SECRET)) {
failCount = 0;
lockUntil = 0;
hidePattern();
triggerPause();
} else { // wrong: count + throttle
failCount++;
long wait = backoffMs(failCount);
if (wait > 0) lockUntil = SystemClock.elapsedRealtime() + wait;
if (patternView != null) patternView.reset(); // clears, then shows lockout
}
}
// Escalating cooldown after wrong attempts, to defeat brute force. The first
// couple are free (parent fat-fingering), then it climbs fast and caps at
// 5 min — with a 6x6 space, that makes guessing hopeless. Tune to taste.
private long backoffMs(int fails) {
switch (fails) {
case 1: case 2: return 0;
case 3: return 10_000;
case 4: return 30_000;
case 5: return 60_000;
default: return 300_000;
}
}
private boolean isLocked() { return SystemClock.elapsedRealtime() < lockUntil; }
private long lockRemainingMs() {
return Math.max(0, lockUntil - SystemClock.elapsedRealtime());
}
private boolean matches(List<Integer> seq, int[] target) {
if (target.length == 0 || seq.size() != target.length) return false;
for (int i = 0; i < target.length; i++) {
if (seq.get(i) != target[i]) return false;
}
return true;
}
// stopLockTask() is an Activity API, so route the actual unlock through
// MainActivity: set the flag and start it. MainActivity sees isPaused(),
// drops lock task, hands HOME to Trebuchet, and stops this service (which
// tears down all our windows via onDestroy).
private void triggerPause() {
MainActivity.sPausedByGesture = true;
startActivity(new Intent(this, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
}
@Override
public void onDestroy() {
super.onDestroy();
try { unregisterReceiver(statusReceiver); } catch (Exception ignored) {}
if (handler != null) handler.removeCallbacksAndMessages(null);
if (row != null && wm != null) { wm.removeView(row); row = null; }
if (hotspot != null && wm != null) { wm.removeView(hotspot); hotspot = null; }
if (patternView != null && wm != null) { wm.removeView(patternView); patternView = null; }
}
@Override
public IBinder onBind(Intent intent) { return null; }
// A minimal GRID x GRID lock-pattern view: drag across dots, they latch in
// order, touch-up reports the sequence. Discrete dots (not free-form shape
// matching) so the parent's pattern is recognised exactly, every time.
private class PatternView extends View {
private final float[] cx = new float[GRID * GRID];
private final float[] cy = new float[GRID * GRID];
private final List<Integer> seq = new ArrayList<>();
private final Paint dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint selPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint lockPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private float radius;
PatternView(Context c) {
super(c);
dotPaint.setColor(0x66FFFFFF);
selPaint.setColor(0xFFFFFFFF);
linePaint.setColor(0xFFFFFFFF);
linePaint.setStrokeWidth(8);
lockPaint.setColor(0x55FF5555); // dim red dots while locked
textPaint.setColor(0xFFFF8888);
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setTextSize(64);
}
void reset() { seq.clear(); invalidate(); }
@Override
protected void onSizeChanged(int w, int h, int ow, int oh) {
float size = Math.min(w, h) * 0.85f; // centred square grid
float left = (w - size) / 2f, top = (h - size) / 2f;
float step = size / (GRID - 1);
radius = step * 0.18f;
for (int i = 0; i < cx.length; i++) {
cx[i] = left + (i % GRID) * step;
cy[i] = top + (i / GRID) * step;
}
}
private int hitDot(float x, float y) {
for (int i = 0; i < cx.length; i++) {
if (Math.hypot(x - cx[i], y - cy[i]) <= radius * 2.0) return i;
}
return -1;
}
@Override
public boolean onTouchEvent(MotionEvent e) {
if (isLocked()) return true; // cooling down: swallow touches, no latching
switch (e.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
int hit = hitDot(e.getX(), e.getY());
if (hit >= 0 && !seq.contains(hit)) { seq.add(hit); invalidate(); }
return true;
case MotionEvent.ACTION_UP:
onPatternComplete(seq);
return true;
}
return true;
}
@Override
protected void onDraw(Canvas canvas) {
boolean locked = isLocked();
for (int i = 1; i < seq.size(); i++) {
int a = seq.get(i - 1), b = seq.get(i);
canvas.drawLine(cx[a], cy[a], cx[b], cy[b], linePaint);
}
for (int i = 0; i < cx.length; i++) {
Paint p = locked ? lockPaint : (seq.contains(i) ? selPaint : dotPaint);
canvas.drawCircle(cx[i], cy[i], radius, p);
}
if (locked) {
long s = (lockRemainingMs() + 999) / 1000;
canvas.drawText(s + "s", getWidth() / 2f, getHeight() * 0.93f, textPaint);
postInvalidateDelayed(500); // tick the countdown; resumes normal draw at 0
}
}
}
}
Build the APK
cd /var/run/user/1000/musicbox
# --refresh re-resolves the flake's branch HEAD each build, so we track its
# latest instead of serving a stale revision from Nix's tarball cache.
nix develop --impure --refresh github:konubinix/devel?dir=flakes/android \
-c gradle --no-daemon assembleDebug >&2
ipfa app/build/outputs/apk/debug/app-debug.apk | sed -r 's/\?filename.+//'
https://ipfs.konubinix.eu/p/bafkreidu5watldpn4sb54ymdqjzhurdga3skduewzgejfz5uwgvlljazym
Install + promote to Device Owner
cid is the /ipfs/… path emitted by build-kiosk; with IPFS mounted we
install straight off the content-addressed store, no build tree needed.
adb install -t because of android:testOnly“true”. =dpm set-device-owner
requires the preconditions confirmed earlier.
adb install -r -t "$cid"
adb shell -T pm path eu.konubinix.deezerkiosk </dev/null
Performing Streamed Install
Success
package:/data/app/eu.konubinix.deezerkiosk-N4B6m9eaHSvAxpUYBfxvSw==/base.apk
adb shell -T dpm set-device-owner \
eu.konubinix.deezerkiosk/.KioskAdmin </dev/null
echo "--- confirm ---"
adb shell -T dumpsys device_policy </dev/null \
| grep -iE "Device Owner" -A2
Success: Device owner set to package ComponentInfo{eu.konubinix.deezerkiosk/eu.konubinix.deezerkiosk.KioskAdmin}
Active admin set to component {eu.konubinix.deezerkiosk/eu.konubinix.deezerkiosk.KioskAdmin}
--- confirm ---
Device Owner:
admin=ComponentInfo{eu.konubinix.deezerkiosk/eu.konubinix.deezerkiosk.KioskAdmin}
name=
The status overlay (StatusOverlayService) needs the
SYSTEM_ALERT_WINDOW app-op. It’s not a runtime permission and there’s no
DPM grant API for it, so set the app-op directly over ADB (the shell is
already root from sanity-check). One-time grant; the setting persists.
adb shell -T appops set eu.konubinix.deezerkiosk \
SYSTEM_ALERT_WINDOW allow </dev/null
echo "--- confirm (should print: allow) ---"
adb shell -T appops get eu.konubinix.deezerkiosk \
SYSTEM_ALERT_WINDOW </dev/null
--- confirm (should print: allow) ---
SYSTEM_ALERT_WINDOW: allow
Engage the lock
Firing MainActivity once runs setLockTaskPackages + startLockTask +
launches Deezer. From this point on it’s a kiosk.
# Clear the paused flag. MainActivity treats anything ≠ "1" as resumed.
adb shell -T setprop debug.kiosk.paused 0 </dev/null
# --ez engage true also clears the in-memory gesture-pause flag, so this
# re-locks even after an on-device pattern unlock (not just an ADB pause).
adb shell -T am start --ez engage true \
-n eu.konubinix.deezerkiosk/.MainActivity </dev/null
sleep 2
echo "--- lock task state (should show LOCKED, not PINNED) ---"
adb shell -T dumpsys activity activities </dev/null \
| grep -iE "mLockTaskModeState|mLockTaskModeTasks" | head -3
echo "--- focus ---"
adb shell -T dumpsys window </dev/null \
| grep -E "mCurrentFocus|mFocusedApp" | head -2
Starting: Intent { cmp=eu.konubinix.deezerkiosk/.MainActivity (has extras) }
--- lock task state (should show LOCKED, not PINNED) ---
mLockTaskModeState=LOCKED mLockTaskPackages (userId:packages)=
mLockTaskModeTasks[TaskRecord{aa7972 #1019 A=eu.konubinix.deezerkiosk U=0 StackId=1 sz=1}]
--- focus ---
mFocusedApp=Token{74a7ee5 ActivityRecord{8d4eddc u0 deezer.android.app/com.deezer.ui.dynamicpage.DynamicPageRootActivity t1020}}
mCurrentFocus=Window{ed300d u0 deezer.android.app/com.deezer.ui.dynamicpage.DynamicPageRootActivity}
Pause the kiosk
When you need to use the device as an admin — change wifi networks, sign into Deezer, install/update apps — release the lock and stop our app so it doesn’t re-engage. The kiosk app stays installed and stays the Device Owner; only its lock-task behaviour is paused.
# 1. Raise the "paused" flag (non-persist system property, wiped on
# reboot). MainActivity reads this and hands HOME off to Trebuchet
# instead of re-engaging when the home intent resolves to us.
adb shell -T setprop debug.kiosk.paused 1 </dev/null
# 2. Drop lock task mode (the DPM LOCKED state).
adb shell -T am task lock stop </dev/null
# 3. Force-stop our app so any cached MainActivity instance dies and the
# fresh one (started by the HOME below) re-reads the property.
adb shell -T am force-stop eu.konubinix.deezerkiosk </dev/null
# 4. HOME → MainActivity → sees paused flag → hands off to Trebuchet.
adb shell -T input keyevent KEYCODE_HOME </dev/null
echo "--- lock task state (should be NONE) ---"
adb shell -T dumpsys activity activities </dev/null \
| grep -iE "mLockTaskModeState" | head -1
echo "--- paused flag ---"
adb shell -T getprop debug.kiosk.paused </dev/null
Activity manager is not in lockTaskMode
--- lock task state (should be NONE) ---
mLockTaskModeState=NONE mLockTaskPackages (userId:packages)=
--- paused flag ---
1
While paused: the home launcher is reachable, Settings opens, status bar pulls down. The kiosk app remains the Device Owner — so anything the admin does in this window can’t grant other apps DO privileges, and a reboot will re-engage the kiosk (the paused flag is non-persist — wiped on boot — and MainActivity is the persistent home, so the framework starts it on the very first user-space tick).
To re-engage on demand without rebooting, re-run engage-kiosk from
earlier in this section. It’s idempotent — calling
setLockTaskPackages + startLockTask on an already-allowlisted set
just re-enters lock task mode.
Pause on-device with the hidden gesture (no ADB, no laptop)
The kiosk-pause block above needs the laptop. For the common case — the parent wants to step in while away from the host — the kiosk also pauses from a hidden gesture on the device itself:
- Long-press the top-left corner for ~3 s (
LONG_PRESS_MS, well above the system’s ~500 ms so a kid’s stray tap won’t trip it). There’s an invisible 56 dp touchable hotspot there; the corner is a dead zone with no Deezer controls, so it steals nothing during normal play. - A 6×6 pattern grid appears over Deezer. Draw the secret pattern
(
Secret.PATTERN, dot indicesrow*GRID + col). A correct pattern drops lock task and hands HOME to Trebuchet — same paused state as kiosk-pause. A wrong pattern clears for a retry; an abandoned grid auto-dismisses after 15 s (PATTERN_TIMEOUT_MS) back to Deezer, still locked.
Brute-force throttle. Wrong attempts trigger an escalating cooldown
(backoffMs): the first two are free (parent fat-fingering), then 10 s,
30 s, 60 s, capped at 5 min. While cooling down the grid greys to red
dots with a seconds countdown and ignores all input. The failCount /
lockUntil counters live on the service (not the per-summon
PatternView) and lockUntil is an elapsedRealtime() deadline — so
dismissing and re-summoning the grid does not reset the wait (otherwise
a kid would just reopen it). A correct pattern resets the count; a reboot
clears it too (fresh process). With a 6×6 space plus this backoff,
guessing is hopeless.
Mechanics: the gesture sets MainActivity.sPausedByGesture (in-memory
static) and starts MainActivity, which calls stopLockTask() (an
Activity API the overlay service can’t call), then the existing
handoffToFallbackHome(). The flag is deliberately not persisted — a
reboot or a process kill clears it, so the kiosk re-engages on the next
boot just like the ADB pause. (The app can’t reuse debug.kiosk.paused:
that property is shell/root-writable only, not from an untrusted_app
process — which is why the gesture carries its own flag.)
To re-engage, re-run engage-kiosk as usual: its --ez engage true
extra clears sPausedByGesture so the lock re-applies even after an
on-device unlock.
Why a pattern and not a fingerprint. A fingerprint can’t unlock this
kiosk without breaking a harder requirement — that the kid can press
the power button and wake straight into Deezer with no auth. Enrolling a
fingerprint forces a PIN/pattern → a secure keyguard the kid would hit
on every wake, and Device-Owner setKeyguardDisabled is a documented
no-op while a secure credential is set. So biometric and keyguard-free
wake are mutually exclusive here. An app-level secret (this gesture)
needs no system credential, so the device stays lock-free.
Setting the secret pattern (hidden from the HTML export)
The pattern is a plain literal in Secret.java (Secret.PATTERN), defined
in the source block back in Declaring the app components. That block
carries :exports none, so org’s HTML export omits it entirely — readers of
the published braindump never see the indices — while org-babel-tangle
still writes the file (tangling ignores :exports). So the value stays in
the source tree and the compiled APK, just not in the exported page.
To set/change it, edit the indices in that block (index = row*6 + col,
0 = top-left, 35 = bottom-right on the 6×6 grid), then re-tangle, rebuild and
reinstall. No on-device step is needed.
(Caveat: build-kiosk pushes the APK to IPFS and records the CID here, so
the pattern is recoverable by decompiling that published APK — only the
HTML page is scrubbed. If you later want it out of the APK too, move it back
to an on-device prefs value provisioned over ADB.)
Lock-screen configuration (must stay non-secure)
For the kid to wake the screen with no prompt, the device must have no
secure lock (Settings → Security → Screen lock → None). With no
credential set, MainActivity (on engage) calls
dpm.setKeyguardDisabled(admin, true) so power-on returns straight to
Deezer — no swipe, no bouncer. Verify there’s no secure lock:
echo "--- lockscreen disabled? (want: true) ---"
adb shell -T locksettings get-disabled </dev/null
echo "--- enrolled fingerprints (want: count 0) ---"
adb shell -T dumpsys fingerprint </dev/null | grep -oE '"count":[0-9]+' | head -1
--- lockscreen disabled? (want: true) ---
true
--- enrolled fingerprints (want: count 0) ---
"count":0
If a secure lock is set, setKeyguardDisabled silently does nothing
and the kid hits the keyguard — clear the lock first (Settings → Security
→ Screen lock → None; removing it also drops any enrolled fingerprints).
Auto power-off when idle
To save the battery when the box is left alone, StatusOverlayService
powers the device off after IDLE_MS (20 min) with no activity, where
activity = a user touch or music playing. So a running playlist keeps it
awake, and so does browsing without playing; only genuine idle (no touch and
no audio) counts.
How each signal is read:
- Touch anywhere. The corner hotspot already sees its own touches; for the
rest of the screen the hotspot window carries
FLAG_WATCH_OUTSIDE_TOUCH, so it receives anACTION_OUTSIDEnotification for every touch on Deezer without consuming it (Deezer still gets the event). Verified to fire under lock task. Each one bumpslastActivity. - Music. A once-a-minute tick (
IDLE_TICK_MS) callsAudioManager.isMusicActive(); while true it bumpslastActivitytoo. - When
elapsedRealtime() - lastActivity ≥ IDLE_MS, it powers off.
Power-off needs root. Android has no app/Device-Owner API to shut down, so
the service runs su -c 'svc power shutdown' (true power-off, not just
screen-off — a sleeping screen still drains Deezer + wifi). The kid powers
back on with the button → boot → the kiosk re-engages.
This requires a one-time grant: the first time the app calls su, LineageOS
shows a “DeezerKiosk voudrait obtenir l’accès root” dialog — tick Se
souvenir de mon choix and Autoriser. Do it while the kiosk is paused
(during setup), because lock task would suppress that dialog. To force the
prompt on demand, briefly drop IDLE_MS so a shutdown fires, or trigger one
su call by other means. (persist.sys.root_access must allow apps —
1 or 3; it’s 3 here.) If the grant is ever lost, the worst case is the
device simply doesn’t auto-power-off — it never powers off unintentionally.
Undo
adb shell -T am task lock stop </dev/null || true
adb shell -T dpm remove-active-admin --user 0 \
eu.konubinix.deezerkiosk/.KioskAdmin </dev/null
adb uninstall eu.konubinix.deezerkiosk
Activity manager is not in lockTaskMode
Success: Admin removed ComponentInfo{eu.konubinix.deezerkiosk/eu.konubinix.deezerkiosk.KioskAdmin}
Success
(Possible because of android:testOnly“true”= on <application>. Without
that flag, dpm remove-active-admin would refuse and we’d have to edit
/data/system/device_owner_2.xml as root + reboot.)
Cleanup: wipe the project source tree
When iterating on the source files via tangle, the build tree at
/var/run/user/1000/musicbox/ is fully regenerable from this org file — wipe
to start over.
rm -rf /var/run/user/1000/musicbox
NEXT
- when deezer is not playing music and no user interaction for 20 minutes, automatically power off — see Auto power-off when idle