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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
FOREGROUND_CHANNEL_ID,
|
||||
getString(R.string.app_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
)
|
||||
// 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
|
||||
)
|
||||
)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
|
||||
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()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
)
|
||||
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user