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 - `SettingsActivity.kt` — countdown seconds + command line, persisted in
`SharedPreferences` (`slocker_launcher_prefs`), defaults `60` / `SharedPreferences` (`slocker_launcher_prefs`), defaults `60` /
`sleep 180`. `sleep 180`.
- `BootReceiver.kt` — on `BOOT_COMPLETED`, starts `KioskBootService` - `BootReceiver.kt` — on `BOOT_COMPLETED`, starts `KioskBootService`.
(**not** `startActivity()` directly — see Known issues). - `KioskBootService.kt` — foreground service
- `KioskBootService.kt` — minimal foreground service (`foregroundServiceType="specialUse"`) that posts a full-screen-intent
(`foregroundServiceType="specialUse"`) that calls `startForeground()` notification (`IMPORTANCE_HIGH` channel) targeting `MainActivity`, then
then launches `MainActivity`, then `stopSelf()`. `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 ## 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 → Magisk) — the actual deployment target. Full pipeline (boot → kiosk →
`su` via Magisk → `bwrap``tmux``sshd`) verified working here. `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 Getting auto-launch to actually run unattended after a real (not
untouched reboot, `MainActivity` starts but stays paused/unresumed manually-touched) reboot took three iterations, each diagnosed from
(`onResume()`, where the countdown starts, never fires) until the user `adb logcat -d | grep -i slockerlauncher` after an untouched reboot —
manually unlocks and switches to it. Likely needs worth reading before changing this path:
`Activity.setShowWhenLocked(true)` + `setTurnScreenOn(true)` in
`onCreate()` so it displays and resumes over the lock screen. Diagnosed, 1. Calling `startActivity()` straight from `BootReceiver` gets logged by
not yet fixed. `ActivityTaskManager` as a blocked "Background activity start" and the
- Manifest declares `POST_NOTIFICATIONS` but nothing requests it at process gets killed by `lmkd` ~2s later, before ever becoming visible.
runtime; `KioskBootService`'s notification may silently not display on 2. Routing through a foreground service (`KioskBootService`) is
API 33+ without it (the foreground-service priority itself still **necessary but not sufficient** — on this device/OS,
applies regardless). `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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -1,20 +1,33 @@
package ro.ceamac.slockerlauncher package ro.ceamac.slockerlauncher
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
/** /**
* A boot-triggered launch of MainActivity directly from BroadcastReceiver.onReceive() * A boot-triggered launch of MainActivity directly from BroadcastReceiver.onReceive()
* was logged by ActivityTaskManager as a "Background activity start" and the process * 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) * was killed by lmkd ~2s later before ever becoming visible.
* before ever becoming visible. Routing through a foreground service first gives the *
* process foreground priority and is a documented exemption for starting an activity * Routing through a foreground service alone was NOT enough on this device (Samsung,
* from the background. * 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() { class KioskBootService : Service() {
@@ -22,31 +35,61 @@ class KioskBootService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val nm = getSystemService(NotificationManager::class.java)
CHANNEL_ID, nm.createNotificationChannel(
getString(R.string.app_name), NotificationChannel(
NotificationManager.IMPORTANCE_LOW 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)) startForeground(
.setContentText("Starting…") FOREGROUND_NOTIFICATION_ID,
.setSmallIcon(android.R.drawable.ic_lock_idle_lock) NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_LOW) .setContentTitle(getString(R.string.app_name))
.build() .setContentText("Starting…")
startForeground(NOTIFICATION_ID, notification) .setSmallIcon(android.R.drawable.ic_lock_idle_lock)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
)
val launchIntent = Intent(this, MainActivity::class.java) val launchIntent = Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .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 return START_NOT_STICKY
} }
companion object { companion object {
const val CHANNEL_ID = "slocker_launcher_boot" const val FOREGROUND_CHANNEL_ID = "slocker_launcher_boot"
const val NOTIFICATION_ID = 1 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 package ro.ceamac.slockerlauncher
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
@@ -23,6 +28,18 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
@@ -32,6 +49,18 @@ class MainActivity : AppCompatActivity() {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
} }
binding.exitButton.setOnClickListener { finishAndRemoveTask() } 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() { override fun onResume() {