Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

キオスク端末の消灯がstartActivity1本頼りで、失敗すると朝まで点灯したままになる

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

常時起動のAndroidキオスク端末が夜間の黒画面表示をBlackScreenActivityへのstartActivity1回だけに頼っていると、その呼び出しがBAL制限やSYSTEM_ALERT_WINDOW未許可で失敗した瞬間、画面は消えないまま朝を迎える。

結論

常時起動のAndroidキオスク端末で、夜間の消灯を「黒いオーバーレイActivityのstartActivity1回」だけに頼っていると、その1回がBAL(Background Activity Launch)制限やSYSTEM_ALERT_WINDOW未許可で失敗した瞬間、画面は消えないまま朝を迎えます。 呼び出しはrunCatchingで囲み、しかも経路そのものを2本用意しないと直りません。

症状

Device Ownerキオスクの夜間消灯は、lockNow()でバックライトごと落とすのではなく、明るさ0+FLAG_KEEP_SCREEN_ONの黒いActivityを前面に出す「擬似消灯」で実装していました。パネルを起こしたままにしておけば、朝は黒オーバーレイを解除するだけで必ず復帰できるからです(lockNow()は機種によっては復帰不能スリープを誘発するため、既定では使いません)。

この黒オーバーレイの起動は、スケジュールを管理するScheduleManager.applyCurrentState()が担っていました。

if (cfg.isCurrentlyInOffPeriod(Calendar.getInstance())) {
    context.startActivity(
        Intent(context, BlackScreenActivity::class.java)
            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
    )
    Log.i(TAG, "applyCurrentState: OFF period -> black screen")
}

一方、PowerController.screenOff()のoverlayモード側は、これに委ねる前提で早期returnするだけでした。つまり黒画面を出す経路は実質1本だけです。このstartActivityが端末側の制約で失敗すると、例外はScheduleAlarmReceiver.onReceive()全体を包むtry/catchまで届き、そこで警告ログを1行残して次のアラームを再武装するだけで処理が終わります。画面には何も出ず、キオスクなので操作者もいません。次にこのアラームが発火するのは翌日の同時刻なので、その日の夜はずっと画面が点いたままになります。

原因

startActivityが失敗し得る理由は2つあります。

  • BAL(Background Activity Launch)制限: Android 10以降、バックグラウンドのアプリからのActivity起動は既定でブロックされます。AlarmManager経由のBroadcastReceiverはフォアグラウンドではないため、この制限に引っかかる余地があります。
  • SYSTEM_ALERT_WINDOW未許可: 黒オーバーレイを他アプリの上に重ねて出すための権限で、端末の初期セットアップや工場出荷後の権限リセットで外れていることがあります。

どちらの場合もstartActivityは例外を投げますが、呼び出し元は**「消灯の唯一の経路」であると同時に「例外を握りつぶすtry/catchの内側」**でした。失敗が起きても画面上には何の兆候も出ず、ログを見に行かない限り気づけません。

直し方

2箇所を直しました。

(1) screenOff()自身にも黒オーバーレイの起動を追加し、経路を2本にする。

fun screenOff(context: Context) {
    if (Config.isNightOffOverlay(context)) {
        Log.i(TAG, "screenOff: overlay mode -> show black overlay (keep-screen-on), lockNow skipped")
        runCatching {
            context.startActivity(
                Intent(context, BlackScreenActivity::class.java)
                    .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
            )
        }.onFailure { Log.w(TAG, "screenOff: black overlay launch failed (SYSTEM_ALERT_WINDOW 未付与?): ${it.message}") }
        return
    }
    // ...
}

ScheduleAlarmReceiverはOFFアラームの発火時にPowerController.screenOff(context)を直接呼んだあと、同じ処理内でScheduleManager.applyCurrentState(context)も呼びます。片方がBAL制限や権限未許可で落ちても、もう一方が黒画面を出せます。BlackScreenActivityの起動フラグはFLAG_ACTIVITY_CLEAR_TOP込みなので、両方成功して多重起動しても実害はありません。

(2) startActivityの呼び出しを全てrunCatchingでガードする。

ScheduleManager.applyCurrentState()側の黒オーバーレイ起動と、ON期間に戻すサイネージActivityの起動、両方をrunCatchingで包み、失敗をログだけに留めるようにしました。

if (Config.autoLaunchSignage(context) && Config.signageUrl(context).isNotBlank()) {
    runCatching {
        context.startActivity(
            Intent(context, SignageActivity::class.java)
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        )
    }.onFailure { Log.w(TAG, "applyCurrentState: signage launch failed (SYSTEM_ALERT_WINDOW 未付与?): ${it.message}") }
}

あわせて、KeepAwakeManager.reassertForegroundIfNeeded()がOFF期間中にサイネージが前面に出ていた場合の再消灯処理も、独自に黒オーバーレイをstartActivityしていたのをやめ、PowerController.screenOff(context)を呼ぶ形に一本化しました。消灯のロジック(overlayかlockNowか、失敗時にどう振る舞うか)を1箇所にまとめることで、再消灯の経路が増えるたびに同じガード漏れを繰り返さないようにしています。

再発防止

この修正は自社レビューのHIGH指摘で見つかり、対応まで完了しています。「消灯の経路を2本にする」「startActivityを全てガードする」の2点に加えて、消灯ロジックの呼び出し元をPowerController.screenOff()1箇所に統一したのが3点目の対策です。以前はKeepAwakeManagerが独自に黒オーバーレイを起動するコードを持っていて、消灯の実装が2箇所に分散していました。呼び出し元が増えるほど「新しい経路にはガードを入れ忘れる」リスクが増えるため、消灯そのものの実装は1箇所に閉じ込め、呼び出し側はscreenOff()を呼ぶだけにしています。

よくある質問

Q1startActivityが失敗しても例外で気づけないのですか

AlarmManagerで発火するBroadcastReceiverのonReceive全体をtry/catchで囲み、次の周期を再武装するためだけに例外を握りつぶしていました。startActivityの失敗はログに警告が1行残るだけで、画面には何も表示されず、次にこのアラームが発火する翌日の同時刻まで黒画面は出ません。

Q2lockNow()でバックライトごと消せば確実ではないですか

確実ですが別のリスクがあります。lockNow()は機種によって復帰不能スリープを誘発するため、既定ではあえて使わず、明るさ0+FLAG_KEEP_SCREEN_ONの黒オーバーレイで擬似的に消灯しています。パネルを起こしたままにすることで、朝はオーバーレイ解除だけで必ず復帰します。

Q32つの経路を用意するだけで直った理由は何ですか

screenOff()自身がoverlayモードで早期returnして何もしていなかったのが問題の本体で、実際の起動はスケジュール側のapplyCurrentState()任せでした。screenOff()にも同じstartActivityを追加し、どちらかがBAL制限で落ちてももう一方が黒画面を出せるようにしました。BlackScreenActivityはsingleTask相当の起動フラグなので、二重起動しても害はありません。

Q4runCatchingで囲むだけで十分な直し方なのですか

startActivityの失敗そのものは、BAL制限やSYSTEM_ALERT_WINDOW未許可という端末側の制約が原因なので、runCatchingだけでは黒画面は出ません。runCatchingが担うのは『1経路が落ちてもプロセスを道連れにしない』ことで、実際に画面を消す信頼性は二経路化の方が担っています。

確認した環境

  • compileSdk 34 / targetSdk 34 / minSdk 26(Kotlin, Android TV Device Owner キオスク)
  • 2026-06-15 にレビュー指摘を受けて修正

この記事の根拠

  • Kotlinファイル 55〜73行目コミット d3ee7b9
  • Kotlinファイル 86〜109行目コミット d3ee7b9
  • Kotlinファイル 98〜107行目コミット d3ee7b9

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。