余った/安いAndroidタブレットを、毎朝6時に勝手に点いて夜7時に消えるデジタルフォトフレーム(=家庭内サイネージ)に仕立てる話。 市販のキオスクアプリ(課金・制約あり)を使わず、100行ちょっとの自作APKで完結させる。 特に Xiaomi の MIUI / HyperOS は罠だらけなので、そこを
adbで突破する手順を全部残す。環境例: Redmi Pad 2(Android 15 / HyperOS 2, 非root)+ 母艦Linux(ビルド&adb)。表示するコンテンツ(スライドショーHTML)は別のサーバが
http://<配信サーバ>:8888/で配っている前提。本稿はタブレット側(表示・電源スケジュール・キオスク化)に集中する。
TL;DR(完成物)
- WebView 1枚で配信サーバのスライドショーを全画面表示するだけのアプリ
- AlarmManager で 6:00 点灯 / 19:00 消灯
- 消灯は Device Admin の
lockNow()=バックライトを物理OFF(黒画面じゃなく本当に消える) - 点灯は
setTurnScreenOn()+setShowWhenLocked() - BOOT で自動起動・HOMEランチャ化でキオスク化、再起動でも復帰
- ビルドは Android Studio 不要。
aapt2 / javac / d8 / apksignerを直叩き - 面倒は全部 MIUI/HyperOS 側。
adbの数値 appop などで突破
課金ゼロ・他人のアプリ依存ゼロ。以下、ハマりどころ込みで順番に。

なぜ自作したか(市販キオスクの限界)
最初は定番の Fully Kiosk Browser を使っていた。が、
- 時刻指定の画面ON/OFFスケジュールは PLUS(有料)
- 画面を物理的に消す
fully.turnScreenOff()は 「JavaScript Interface」設定がONでないと無反応(デフォルトOFF) - MIUIのジェスチャと設定メニューのスワイプが競合して設定UIにadbから入れない
「夜だけ本当に消す」を無料・確実にやろうとすると、結局自分で書いたほうが早くて堅いという結論になった。WebViewでスライドショーHTMLを表示するだけなら、アプリ本体は本当に小さい。
第1章: Android Studio無しで最小APKを手ビルドする
CIや母艦サーバ上で「gradleもAndroid Studioも入れたくない、SDKのコマンドラインツールだけでAPKを吐きたい」というとき用。
SDK を最小で用意
# JDK 17 だけ先に入れておく(apt install openjdk-17-jdk など)
mkdir -p ~/Android/Sdk/cmdline-tools && cd ~/Android/Sdk/cmdline-tools
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O c.zip
unzip -q c.zip && rm -rf latest && mv cmdline-tools latest && rm c.zip
SDKM=~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager
yes | "$SDKM" --sdk_root="$HOME/Android/Sdk" --licenses >/dev/null
yes | "$SDKM" --sdk_root="$HOME/Android/Sdk" \
"platform-tools" "platforms;android-35" "build-tools;35.0.0"
これで aapt2 / d8 / apksigner / zipalign / android.jar が揃う。
手ビルドスクリプト(gradle不要)
純Java(androidx不使用)なら、ビルドはこれだけ:
#!/usr/bin/env bash
set -euo pipefail
SDK="$HOME/Android/Sdk"; BT="$SDK/build-tools/35.0.0"
JAR="$SDK/platforms/android-35/android.jar"
APP="$PWD"; OUT="$APP/build"
rm -rf "$OUT"; mkdir -p "$OUT/gen" "$OUT/obj" "$OUT/apk"
# 1) リソース(コンパイル済みは -R ではなく“位置引数”で渡すのがポイント)
"$BT/aapt2" compile --dir "$APP/res" -o "$OUT/res.zip"
"$BT/aapt2" link -o "$OUT/apk/base.apk" -I "$JAR" \
--manifest "$APP/AndroidManifest.xml" --java "$OUT/gen" \
--min-sdk-version 29 --target-sdk-version 35 "$OUT/res.zip"
# 2) javac(android.jar を bootclasspath に)
find "$APP/src" "$OUT/gen" -name '*.java' > "$OUT/srcs.txt"
javac -source 8 -target 8 -nowarn -bootclasspath "$JAR" -d "$OUT/obj" @"$OUT/srcs.txt"
# 3) dex
"$BT/d8" --min-api 29 --lib "$JAR" --output "$OUT/apk" $(find "$OUT/obj" -name '*.class')
# 4) classes.dex を apk に同梱 → アライン → 署名
( cd "$OUT/apk" && zip -uj base.apk classes.dex >/dev/null )
KS="$HOME/.android/debug.keystore"
[ -f "$KS" ] || keytool -genkeypair -keystore "$KS" -storepass android -keypass android \
-alias androiddebugkey -dname "CN=Android Debug" -keyalg RSA -keysize 2048 -validity 10000
"$BT/zipalign" -f -p 4 "$OUT/apk/base.apk" "$OUT/apk/aligned.apk"
"$BT/apksigner" sign --ks "$KS" --ks-pass pass:android --key-pass pass:android \
--out "$APP/app.apk" "$OUT/apk/aligned.apk"
echo "OK -> $APP/app.apk"
ハマり①: aapt2 link の -R
コンパイル済みリソース(res.zip)を -R で渡すと resource string/app_name does not override an existing resource で死ぬ。 -R はオーバーレイ用。素のリソースは位置引数で渡す。
ハマり②: ラムダが壊れる
-bootclasspath android.jar で javac すると、ラムダで シンボルを見つけられません: メソッド metafactory ... LambdaMetafactory になる。 android.jar には LambdaMetafactory の実体が無いため。匿名クラスに書き換えるのが手っ取り早い:
// NG: handler.postDelayed(() -> webview.reload(), 4000);
handler.postDelayed(new Runnable() {
@Override public void run() { webview.reload(); }
}, 4000);
(-nowarn でも source value 8 is obsolete 警告は出るが無害)
第2章: 非rootタブレットで「定時に画面ON/OFF」する
ここが市販アプリに課金させられがちな部分。rootなしでもできる。
消灯 — Device Admin の lockNow()
通常アプリには「画面を消す」APIは無いが、デバイス管理者になれば DevicePolicyManager.lockNow() で画面を消せる(force-lock ポリシー)。
// AdminReceiver.java
public class AdminReceiver extends android.app.admin.DeviceAdminReceiver {}
<!-- res/xml/device_admin.xml -->
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies><force-lock/></uses-policies>
</device-admin>
DevicePolicyManager dpm = (DevicePolicyManager) ctx.getSystemService(Context.DEVICE_POLICY_SERVICE);
ComponentName admin = new ComponentName(ctx, AdminReceiver.class);
if (dpm.isAdminActive(admin)) dpm.lockNow(); // ← バックライトが物理的に落ちる
デバイス管理者の有効化は、ユーザーにダイアログを踏ませなくても adb でいける:
adb shell dpm set-active-admin <pkg>/.AdminReceiver
点灯 — AlarmManager + 画面ON属性
setExactAndAllowWhileIdle() で 6:00 / 19:00 に正確アラームを仕込み、6:00 側で Activity を前面化。Activity は「来たら画面を点ける」属性を持たせる:
// MainActivity#onCreate
if (Build.VERSION.SDK_INT >= 27) {
setShowWhenLocked(true); // ロック画面の上でも出す
setTurnScreenOn(true); // 前面化したら画面を点ける
}
((KeyguardManager) getSystemService(KEYGUARD_SERVICE)).requestDismissKeyguard(this, null);
// 次の hh:00 を計算して setExactAndAllowWhileIdle、発火後に翌日分を再セット
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextAt(6), wakePI);
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextAt(19), sleepPI);
Manifest 側の許可:
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- バックグラウンドからActivityを起こすための保険 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
フォトフレームなのでロック画面はそもそも邪魔。
adb shell locksettings set-disabled trueで無効化しておくと、点灯時にキーガードを跨がず素直に出る。
第3章: MIUI / HyperOS キオスク化の罠とadb突破【本命】
ここが今回いちばん時間を溶かした所。Xiaomi 機は標準Androidの常識が通じない。順に潰す。
罠1: adb install が INSTALL_FAILED_USER_RESTRICTED
Failure [INSTALL_FAILED_USER_RESTRICTED: Install canceled by user]
開発者向けオプションの「USB経由でインストール」制限。pm install 直叩きでも、file:// でインストーラを開いても弾かれる(HyperOSはMiアカウント認証が絡んで有効化も渋い)。
回避: APKを端末に置いてファイルアプリからタップしてインストール(“ユーザー操作”はこの制限の対象外)。Play Protectの「スキャンに送信しますか?」が出るが、どれを選んでもインストールは進む。
adb push app.apk /sdcard/Download/ # → 端末のファイルアプリでタップ
そして重要: 一度入れてしまえば、同一署名の“更新”は adb install -r で通る。=初回だけ手で入れれば、以降の反復はadbで回せる。
罠2: 6:00に画面が点かない → Show when locked PermissionDenied
アラームでActivityは起動するのに画面が点かない。logcat を見ると:
ActivityRecordImpl: MIUILOG- Show when locked PermissionDenied pkg : com.example.frame
MIUIが「ロック画面に表示/バックグラウンド起動」権限を拒否してActivityを破棄している(標準の setShowWhenLocked を握り潰す)。これが効かないと点灯が全部不発になる。
正体は数値appop。パッケージのopを見ると MIUIOP(10020) が rejectTime 付きで拒否されている:
adb shell appops get <pkg> | grep MIUIOP
# MIUIOP(10020): ignore; rejectTime=... ← これ
# MIUIOP(10021): ignore
数値で allow にすれば突破できる(標準の文字列op名は通らないが、数値ならいける):
adb shell appops set <pkg> 10020 allow # ロック画面表示/バックグラウンド起動
adb shell appops set <pkg> 10021 allow # 自動起動 等
これでアラーム→Activity前面化→setTurnScreenOnが通って点灯するようになる。MIUIキオスクの肝はここ。(端末UIなら「その他の権限 → ロック画面に表示」「自動起動」に相当)
罠3: 背景VPNがすぐ殺される(VPN経由で配信している場合)
配信元をTailscale等のVPN IPで参照していると、MIUIの省電力がVPNアプリを落として読み込み不能になる。常時接続VPN化で延命:
adb shell settings put secure always_on_vpn_app <vpnパッケージ>
ただし後述のとおり、同一LANならVPNを使わずLAN直で参照するのが結局いちばん堅い。
仕上げの adb 一式(再起動後も永続)
PKG=com.example.frame
adb shell dpm set-active-admin $PKG/.AdminReceiver # 消灯権限
adb shell appops set $PKG 10020 allow # ロック画面表示/背景起動
adb shell appops set $PKG 10021 allow # 自動起動
adb shell appops set $PKG SYSTEM_ALERT_WINDOW allow # 背景からのActivity起動
adb shell appops set $PKG SCHEDULE_EXACT_ALARM allow # 正確アラーム
adb shell dumpsys deviceidle whitelist +$PKG # 電池最適化の除外
adb shell cmd package set-home-activity $PKG/.MainActivity # HOMEランチャ化(自動起動&キオスク)
adb shell locksettings set-disabled true # ロック画面オフ
set-home-activity で自分をHOMEにしておくと、起動時に勝手に立ち上がり、ホーム操作でも常にフレームに戻る=実質キオスク。これらは更新インストールしても保持される。
第4章: adbだけでキオスク端末をデバッグする
画面の無い/触りたくない端末を母艦から検証する小ワザ。
# いま画面は点いてる?消えてる?(Awake / Dozing / Asleep)
adb shell dumpsys power | grep mWakefulness
# スクショの“ファイルサイズ”で状態を雑判定(写真=数MB / 黒画面=20KB前後)
adb shell screencap -p /sdcard/s.png; adb shell stat -c %s /sdcard/s.png
# ダイアログのボタン座標を取ってタップ(Play Protect等の自動処理)
adb shell uiautomator dump /sdcard/u.xml
adb shell cat /sdcard/u.xml | tr '>' '\n' | grep -oE 'text="[^"]*"|bounds="\[[0-9,]+\]\[[0-9,]+\]"'
adb shell input tap 800 1844
# MIUI独自の拒否理由は MIUILOG に出る
adb shell logcat -d | grep MIUILOG
「写真が出ない」系は アプリは前面(mCurrentFocus)だが配信が取れてない のか、そもそも画面が消えてる(mWakefulness=Dozing) のかを最初に切り分けると速い。
第5章: 設計で学んだこと
配信元は「VPNより素のLAN優先」
表示元URLを VPN IP(例 100.x.x.x:8888)だけにしていたら、再起動直後にVPNが復帰する前にWebViewが読みに行って、ローディングのまま固まった。表示機と配信サーバが同一LANにいるなら、LAN直(192.168.x.x:8888)を第一候補にして、VPNは“別拠点用のフォールバック”に回すのが堅い。
static final String[] URLS = {
"http://192.168.0.50:8888/", // 同一LAN:ブート直後でもWi-Fiさえ上がれば即表示
"http://100.x.x.x:8888/", // フォールバック:別拠点(VPN)
};
// WebViewClient#onReceivedError でメインフレーム失敗時に次URLへ切替え+再試行
ブート直後はWi-Fiも一瞬切れているので、onReceivedError で数秒おきにURLを巡回させて自己回復させる。
市販キオスクを捨てた判断
「時刻スケジュール」「画面の物理ON/OFF」が有料 or 設定依存で詰まったら、WebView+AlarmManager+Device Adminの自作に切り替えたほうが速い。コアは本当に小さい(Activity+Receiver数本)。“他人のアプリの制約”をデバッグする時間が一番もったいない。
落とし穴チートシート
| 症状 | 原因 | 対処 |
|---|---|---|
aapt2 link で does not override | res.zip を -R で渡した | 位置引数で渡す |
ラムダで LambdaMetafactory エラー | android.jar bootclasspath | 匿名クラスに書換え |
adb install が USER_RESTRICTED | MIUI「USB経由でインストール」 | 初回はタップ導入/更新はadbで通る |
| アラームで画面が点かない | MIUILOG Show when locked PermissionDenied | appops set <pkg> 10020/10021 allow |
| 背景でアプリ/VPNが死ぬ | MIUI省電力 | deviceidle whitelist +/always_on_vpn_app/自動起動op |
| 点灯時にロック画面を跨ぐ | キーガード | locksettings set-disabled true |
| 再起動後ローディングで固まる | VPN復帰待ち | 表示URLをLAN優先に |
まとめ
- WebView + AlarmManager + Device Admin だけで、非rootタブレットが「定時に点いて消える」サイネージになる
- ビルドは SDKコマンドラインツール直叩きで十分(gradle不要)
- 最大の敵は端末メーカー(MIUI/HyperOS)。
adbの数値appop10020/10021を筆頭に、ここで挙げたコマンドで突破できる - 市販キオスクの制約に時間を溶かすくらいなら、100行の自作が結局いちばん速くて堅い
余ったタブレットがあるなら、写真でも天気でも予定表でも、好きなものを“勝手に点いて勝手に消える”常設ディスプレイにできる。よい自宅サイネージ生活を。
*(環境: Redmi Pad 2 / Android 15・HyperOS 2 / 非root。appopの数値やMIUIの挙動は機種・ビルドで変わり得るので、appops get <pkg> と logcat | grep MIUILOG で各自確認のこと)*

コメントを残す