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
|
- `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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user