Rebounder Tech Blog

Written by the people who actually run these systems in production.

One startActivity Is Not Enough to Blank a Kiosk Screen

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

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

結論

If an Android kiosk blanks its screen with a single startActivity to a BlackScreenActivity, the display stays lit until morning the moment BAL restrictions or a missing SYSTEM_ALERT_WINDOW block it.

Conclusion

When an always-on Android kiosk blanks its screen at night with a single startActivity to a black overlay Activity, that one call failing to a BAL (Background Activity Launch) restriction or a missing SYSTEM_ALERT_WINDOW leaves the screen lit until morning. Wrapping the call in runCatching is not enough; the path itself has to exist twice.

Symptom

Night blanking on a Device Owner kiosk is not done with lockNow(). Instead a black Activity with brightness 0 and FLAG_KEEP_SCREEN_ON is brought to the front — a pseudo-blank. Keeping the panel awake means morning recovery is always just removing the overlay. (lockNow() can trigger an unwakeable sleep on some models, so it is not the default.)

Launching that black overlay was the job of 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")
}

Meanwhile the overlay branch of PowerController.screenOff() simply returned early, assuming the schedule side would handle it. In practice there was exactly one path to a black screen. When that startActivity failed to a device-side constraint, the exception reached the try/catch wrapping the whole of ScheduleAlarmReceiver.onReceive(), which logged a warning, re-armed the next alarm, and finished. Nothing appeared on screen, and on a kiosk there is no operator watching. The next time that alarm fires is the same time the following day, so the display stays on for the rest of the night.

Cause

There are two reasons startActivity can fail here.

  • BAL (Background Activity Launch) restrictions: since Android 10, launching an Activity from a background app is blocked by default. A BroadcastReceiver woken by AlarmManager is not in the foreground, so it can fall foul of this.
  • Missing SYSTEM_ALERT_WINDOW: the permission that lets the black overlay sit above other apps. It can be absent after initial setup or a factory reset of permissions.

Either way startActivity throws — but the call site was both the only path to blanking and inside a try/catch that swallowed the exception. Nothing showed on screen, and without going to look at the log there was no way to know.

The fix

Two changes.

(1) Add the overlay launch to screenOff() itself, so there are two paths.

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 missing?): ${it.message}") }
        return
    }
    // ...
}

On an OFF alarm, ScheduleAlarmReceiver calls PowerController.screenOff(context) directly and then, in the same run, also calls ScheduleManager.applyCurrentState(context). If one is blocked by BAL or a missing permission, the other can still produce the black screen. BlackScreenActivity launches with FLAG_ACTIVITY_CLEAR_TOP, so both succeeding does no harm.

(2) Guard every startActivity with runCatching.

Both the overlay launch in ScheduleManager.applyCurrentState() and the signage Activity launch that returns the device to the ON period were wrapped, so a failure is confined to a log line.

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 missing?): ${it.message}") }
}

KeepAwakeManager.reassertForegroundIfNeeded(), which re-blanks when the signage Activity has come to the front during an OFF period, used to launch its own black overlay. That was replaced with a call to PowerController.screenOff(context), consolidating the blanking logic — overlay or lockNow, and how to behave on failure — in one place so the same missing guard does not reappear each time a new path is added.

Preventing a repeat

This came out of an internal review as a HIGH finding and is fully addressed. Alongside “two paths to blanking” and “guard every startActivity”, the third measure was consolidating all blanking behind PowerController.screenOff(). Previously KeepAwakeManager had its own overlay launch, so the implementation lived in two places. The more call sites there are, the more likely a new one ships without the guard; keeping the blanking itself in one place means callers only ever call screenOff().

よくある質問

Q1Doesn't a failing startActivity show up as an exception?

The whole onReceive of the AlarmManager BroadcastReceiver was wrapped in try/catch purely to re-arm the next cycle. A failed startActivity leaves one warning line in the log, nothing on screen, and no black screen until the same alarm fires again the next day.

Q2Wouldn't lockNow() be more reliable, killing the backlight outright?

It would, but it carries another risk: on some models lockNow() triggers a sleep the device cannot wake from. By default we blank with brightness 0 plus a FLAG_KEEP_SCREEN_ON overlay instead. Keeping the panel awake means morning recovery is only ever removing the overlay.

Q3Why did adding a second path fix it?

The real problem was screenOff() returning early in overlay mode and doing nothing, leaving the launch to applyCurrentState(). The same startActivity was added to screenOff() so either path can blank the screen. BlackScreenActivity launches with CLEAR_TOP, so a double launch is harmless.

Q4Is wrapping it in runCatching a sufficient fix?

No. The failure itself comes from device-side constraints — BAL restrictions or a missing SYSTEM_ALERT_WINDOW — so runCatching alone still leaves the screen lit. runCatching only ensures one failing path does not take the process with it. The reliability comes from having two paths.

確認した環境

  • compileSdk 34 / targetSdk 34 / minSdk 26 (Kotlin, Android TV Device Owner kiosk)
  • Fixed 2026-06-15 after a review finding

この記事の根拠

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

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