格安Androidタブレットを「自作キオスクAPK」でデジタルフォトフレーム化する — MIUI/HyperOSのadb突破ぜんぶ載せ

夜は消灯・朝は点灯するデジタルフォトフレーム

余った/安い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 などで突破

課金ゼロ・他人のアプリ依存ゼロ。以下、ハマりどころ込みで順番に。


フォトフレーム端末とコンテンツ配信サーバの構成図(WebView・6:00 ON/19:00 OFF・配信:8888)
図: フォトフレーム端末(WebViewで表示・定時ON/OFF)と、写真を配るコンテンツ配信サーバの関係

なぜ自作したか(市販キオスクの限界)

最初は定番の 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.jarjavac すると、ラムダで シンボルを見つけられません: メソッド 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 installINSTALL_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 linkdoes not overrideres.zip-R で渡した位置引数で渡す
ラムダで LambdaMetafactory エラーandroid.jar bootclasspath匿名クラスに書換え
adb installUSER_RESTRICTEDMIUI「USB経由でインストール」初回はタップ導入/更新はadbで通る
アラームで画面が点かないMIUILOG Show when locked PermissionDeniedappops 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 の数値appop 10020/10021 を筆頭に、ここで挙げたコマンドで突破できる
  • 市販キオスクの制約に時間を溶かすくらいなら、100行の自作が結局いちばん速くて堅い

余ったタブレットがあるなら、写真でも天気でも予定表でも、好きなものを“勝手に点いて勝手に消える”常設ディスプレイにできる。よい自宅サイネージ生活を。


*(環境: Redmi Pad 2 / Android 15・HyperOS 2 / 非root。appopの数値やMIUIの挙動は機種・ビルドで変わり得るので、appops get <pkg>logcat | grep MIUILOG で各自確認のこと)*

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

IP: 取得中...
216.73.216.151216.73.216.151