Fix unattended boot auto-launch

Plain startActivity() from BootReceiver (and even from a foreground
service) gets blocked as a background activity start and the process
gets reclaimed before becoming visible. Switch to a full-screen-intent
notification (the mechanism alarm/call apps use), which needs
USE_FULL_SCREEN_INTENT + POST_NOTIFICATIONS granted, plus
setShowWhenLocked/setTurnScreenOn on MainActivity so it displays over
the lock screen. Confirmed working on an untouched real-device reboot.
This commit is contained in:
2026-09-14 06:05:50 +00:00
parent fb4d10467a
commit bf91d8ec52
4 changed files with 128 additions and 37 deletions
+35 -17
View File
@@ -22,11 +22,19 @@ targetSdk 37.
- `SettingsActivity.kt` — countdown seconds + command line, persisted in
`SharedPreferences` (`slocker_launcher_prefs`), defaults `60` /
`sleep 180`.
- `BootReceiver.kt` — on `BOOT_COMPLETED`, starts `KioskBootService`
(**not** `startActivity()` directly — see Known issues).
- `KioskBootService.kt` — minimal foreground service
(`foregroundServiceType="specialUse"`) that calls `startForeground()`
then launches `MainActivity`, then `stopSelf()`.
- `BootReceiver.kt` — on `BOOT_COMPLETED`, starts `KioskBootService`.
- `KioskBootService.kt` — foreground service
(`foregroundServiceType="specialUse"`) that posts a full-screen-intent
notification (`IMPORTANCE_HIGH` channel) targeting `MainActivity`, then
`stopSelf()`s after a short delay. This is deliberately **not** a plain
`startActivity()` call from the receiver/service — see the note below
on why.
- `MainActivity.onCreate()` also calls `setShowWhenLocked(true)` +
`setTurnScreenOn(true)` (API 27+) so it can display over the lock
screen, and opportunistically requests `POST_NOTIFICATIONS` at runtime
(needed for the full-screen intent above to fire on API 33+; can only
be requested from an Activity, so this only takes effect starting the
*next* boot after a manual open grants it).
## Build & install
@@ -48,16 +56,26 @@ Debug-signed APK is fine for sideloading; no release signing config exists.
Magisk) — the actual deployment target. Full pipeline (boot → kiosk →
`su` via Magisk → `bwrap``tmux``sshd`) verified working here.
## Known issues
## Why boot-launch works the way it does
- **Boot auto-launch stalls behind the lock screen.** On a genuinely
untouched reboot, `MainActivity` starts but stays paused/unresumed
(`onResume()`, where the countdown starts, never fires) until the user
manually unlocks and switches to it. Likely needs
`Activity.setShowWhenLocked(true)` + `setTurnScreenOn(true)` in
`onCreate()` so it displays and resumes over the lock screen. Diagnosed,
not yet fixed.
- Manifest declares `POST_NOTIFICATIONS` but nothing requests it at
runtime; `KioskBootService`'s notification may silently not display on
API 33+ without it (the foreground-service priority itself still
applies regardless).
Getting auto-launch to actually run unattended after a real (not
manually-touched) reboot took three iterations, each diagnosed from
`adb logcat -d | grep -i slockerlauncher` after an untouched reboot —
worth reading before changing this path:
1. Calling `startActivity()` straight from `BootReceiver` gets logged by
`ActivityTaskManager` as a blocked "Background activity start" and the
process gets killed by `lmkd` ~2s later, before ever becoming visible.
2. Routing through a foreground service (`KioskBootService`) is
**necessary but not sufficient** — on this device/OS,
`startActivity()` from a genuinely-`FOREGROUND_SERVICE` process was
*still* logged as a blocked background start.
3. The mechanism Android actually exempts for this is a
**full-screen-intent notification** (same one alarm/call apps use) —
needs `USE_FULL_SCREEN_INTENT` (manifest, normal/auto-granted) *and*
`POST_NOTIFICATIONS` (runtime, API 33+) both granted, or the intent
silently never fires.
Confirmed working end to end on the real device (Galaxy A71, Android 13):
untouched reboot → full-screen-intent notification → `MainActivity`
resumes on its own → kiosk black screen → configured `su -c` command.
+1
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application
android:allowBackup="true"
@@ -1,20 +1,33 @@
package ro.ceamac.slockerlauncher
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
/**
* A boot-triggered launch of MainActivity directly from BroadcastReceiver.onReceive()
* was logged by ActivityTaskManager as a "Background activity start" and the process
* was killed by lmkd ~2s later (oom_score_adj 925, i.e. still treated as background)
* before ever becoming visible. Routing through a foreground service first gives the
* process foreground priority and is a documented exemption for starting an activity
* from the background.
* was killed by lmkd ~2s later before ever becoming visible.
*
* Routing through a foreground service alone was NOT enough on this device (Samsung,
* Android 13): logcat showed the startActivity() call still logged as a blocked
* "Background activity start" (allowBackgroundActivityStart: false) even while the
* calling process was genuinely in FOREGROUND_SERVICE state. The mechanism Android
* actually exempts for "show full-screen UI immediately, even from background/locked"
* is a full-screen-intent notification (what alarm/incoming-call apps use) — that goes
* through a different code path in ActivityTaskManager than a plain startActivity().
*
* Needs POST_NOTIFICATIONS granted (requested at runtime from MainActivity) — without
* it, notify() is silently dropped and the full-screen intent never fires.
*/
class KioskBootService : Service() {
@@ -22,31 +35,61 @@ class KioskBootService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel(
FOREGROUND_CHANNEL_ID,
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW
)
)
// Full-screen intents require an IMPORTANCE_HIGH channel to actually fire.
nm.createNotificationChannel(
NotificationChannel(
FULLSCREEN_CHANNEL_ID,
getString(R.string.app_name),
NotificationManager.IMPORTANCE_HIGH
)
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText("Starting…")
.setSmallIcon(android.R.drawable.ic_lock_idle_lock)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(NOTIFICATION_ID, notification)
startForeground(
FOREGROUND_NOTIFICATION_ID,
NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText("Starting…")
.setSmallIcon(android.R.drawable.ic_lock_idle_lock)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
)
val launchIntent = Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(launchIntent)
val pendingIntent = PendingIntent.getActivity(
this, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val fullScreenNotification = NotificationCompat.Builder(this, FULLSCREEN_CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText("Starting…")
.setSmallIcon(android.R.drawable.ic_lock_idle_lock)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_ALARM)
.setFullScreenIntent(pendingIntent, true)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(this).notify(FULLSCREEN_NOTIFICATION_ID, fullScreenNotification)
stopSelf()
// Keep the foreground-service protection window open briefly so the process
// doesn't get reclaimed before the full-screen intent actually lands.
Handler(Looper.getMainLooper()).postDelayed({ stopSelf() }, 2000L)
return START_NOT_STICKY
}
companion object {
const val CHANNEL_ID = "slocker_launcher_boot"
const val NOTIFICATION_ID = 1
const val FOREGROUND_CHANNEL_ID = "slocker_launcher_boot"
const val FULLSCREEN_CHANNEL_ID = "slocker_launcher_fullscreen"
const val FOREGROUND_NOTIFICATION_ID = 1
const val FULLSCREEN_NOTIFICATION_ID = 2
}
}
@@ -1,12 +1,17 @@
package ro.ceamac.slockerlauncher
import android.Manifest
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
import android.view.View
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -23,6 +28,18 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Boot-launched via KioskBootService: without this, the activity is created
// but stays behind the lock screen (never resumed, countdown never starts)
// until the user manually unlocks and switches to it.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
@@ -32,6 +49,18 @@ class MainActivity : AppCompatActivity() {
startActivity(Intent(this, SettingsActivity::class.java))
}
binding.exitButton.setOnClickListener { finishAndRemoveTask() }
// Needed for KioskBootService's full-screen-intent boot notification to fire on
// API 33+; can only be requested from an Activity, not a receiver/service, so this
// opportunistic ask (once, on manual open) is what makes the next auto-boot work.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1
)
}
}
override fun onResume() {