Here is a scenario you will run into sooner or later building Android kiosk apps, digital signage, or any long-running background service: you need to know when the user has not interacted with the device for a certain amount of time. Maybe you want to show content after 20 seconds of idle time, then dismiss it the instant someone touches the screen again.
You start looking for the right API. PowerManager.isInteractive() tells you if the screen is on, not if anyone is actually using it. onUserInteraction() in an Activity only fires when your own Activity is in the foreground. There is no getLastInteractionTime() anywhere in the SDK.
Eventually you land on AccessibilityService, try it, and it works perfectly. This article walks you through exactly how, and why.
What is AccessibilityService really doing here
AccessibilityService is designed for assistive technology — screen readers, switch access, voice control. That is its primary documented purpose. But what makes it useful for our use case is a side effect of how it works: the service receives accessibility events from any app on the device, system-wide, while running in the background.
These events include:
-
TYPE_TOUCH_INTERACTION_START— user touched the screen -
TYPE_VIEW_CLICKED— user tapped a view -
TYPE_VIEW_SCROLLED— user scrolled content -
TYPE_GESTURE_DETECTION_START— a gesture was recognized
In other words: as long as the screen is on and the user is doing something, your service receives a steady stream of events. When that stream goes quiet, the user is idle.
A word on Google Play policy: AccessibilityService is a privileged API. Google Play has strict rules about what justifies its use. For kiosk apps, enterprise deployments, or apps distributed outside the Play Store, this is a legitimate tool. Never use it to collect sensitive data or surveil users. If you are building a consumer app, check whether your use case actually qualifies before submitting to the store.
Prerequisites
- Android Studio Hedgehog or later
- Min SDK 26+
- Kotlin 1.9+
- Basic familiarity with Android Services
Step 1 — Declare the service in the Manifest
The service requires the BIND_ACCESSIBILITY_SERVICE permission. Android enforces this at the system level — only the OS can bind to your service, not other apps.
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE" />
<service
android:name=".service.UserActivityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
android:exported="true" is required because the system binds to the service from outside your process.
Step 2 — Configure the service with an XML file
Create res/xml/accessibility_service_config.xml. This file is how you tell Android what events to deliver and how to present the service to the user in Settings.
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagIncludeNotImportantViews"
android:canRetrieveWindowContent="false"
android:description="@string/accessibility_service_description"
android:summary="@string/accessibility_service_summary"
android:notificationTimeout="100"
android:packageNames="@null" />
Each attribute is a deliberate choice:
| Attribute | Value | Why |
|---|---|---|
accessibilityEventTypes |
typeAllMask |
We want any interaction event, so we use the broad mask |
canRetrieveWindowContent |
false |
We do not need to read screen content — only that an event happened. Always set this to false unless you specifically need it. |
packageNames |
@null |
Listen to events from all packages, not just our own |
notificationTimeout |
100 |
Minimum ms between events. Prevents flooding without missing real interactions |
Add the string resources referenced above:
<!-- res/values/strings.xml -->
<string name="accessibility_service_description">
Detects user interactions to manage screen idle state.
</string>
<string name="accessibility_service_summary">
Required to detect when the screen is idle.
</string>
These strings appear in the Accessibility settings page. Write them clearly — the user will read them before enabling your service.
Step 3 — Implement the AccessibilityService
class UserActivityService : AccessibilityService() {
interface ActivityCallback {
fun onUserActivity()
}
companion object {
private var activityCallback: ActivityCallback? = null
fun setActivityCallback(callback: ActivityCallback?) {
activityCallback = callback
}
fun isServiceEnabled(context: Context): Boolean {
val componentName = ComponentName(context, UserActivityService::class.java)
val enabledServices = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
) ?: return false
val splitter = TextUtils.SimpleStringSplitter(':')
splitter.setString(enabledServices)
while (splitter.hasNext()) {
val enabled = ComponentName.unflattenFromString(splitter.next())
if (enabled == componentName) return true
}
return false
}
}
override fun onServiceConnected() {
serviceInfo = serviceInfo.apply {
eventTypes =
AccessibilityEvent.TYPE_TOUCH_INTERACTION_START or
AccessibilityEvent.TYPE_VIEW_CLICKED or
AccessibilityEvent.TYPE_VIEW_SCROLLED or
AccessibilityEvent.TYPE_GESTURE_DETECTION_START
}
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
activityCallback?.onUserActivity()
}
override fun onInterrupt() {}
override fun onDestroy() {
super.onDestroy()
activityCallback = null
}
}
A few things worth unpacking.
We override serviceInfo inside onServiceConnected() to narrow down the event types at runtime. The XML config sets the initial filter, but you can refine it programmatically here. For inactivity detection, the four event types above cover every meaningful user interaction without the noise of TYPE_WINDOW_STATE_CHANGED or TYPE_ANNOUNCEMENT.
The companion object is a static bridge between the service and your app. Android manages the AccessibilityService lifecycle independently — you cannot instantiate it yourself. So instead of trying to hold a reference to the service, you register a callback on the companion object, and the service calls it when events arrive.
Step 4 — Build the InactivityDetector
The service tells you that the user is active. The InactivityDetector handles the how long — tracking elapsed time and firing callbacks when the user crosses the idle threshold.
class InactivityDetector(
private val timeoutSeconds: Int,
private val callback: Callback
) {
interface Callback {
fun onInactive()
fun onActive()
}
private val handler = Handler(Looper.getMainLooper())
private var lastActivityTime = System.currentTimeMillis()
private var isActive = true
private var isRunning = false
private val checkRunnable = object : Runnable {
override fun run() {
if (!isRunning) return
checkInactivity()
handler.postDelayed(this, CHECK_INTERVAL_MS)
}
}
fun start() {
if (isRunning) return
isRunning = true
lastActivityTime = System.currentTimeMillis()
handler.postDelayed(checkRunnable, CHECK_INTERVAL_MS)
}
fun stop() {
isRunning = false
handler.removeCallbacks(checkRunnable)
}
fun onUserActivity() {
lastActivityTime = System.currentTimeMillis()
if (!isActive) {
isActive = true
callback.onActive()
}
}
private fun checkInactivity() {
val elapsed = System.currentTimeMillis() - lastActivityTime
if (isActive && elapsed >= timeoutSeconds * 1000L) {
isActive = false
callback.onInactive()
}
}
companion object {
private const val CHECK_INTERVAL_MS = 1000L
}
}
The check runs on the main thread via Handler(Looper.getMainLooper()). This is intentional: any UI changes triggered by the callbacks need to run on the main thread anyway, and keeping state management single-threaded eliminates a class of race conditions.
onActive() only fires on the transition from inactive to active. If the user is already active and sends another event, we silently update lastActivityTime without re-firing the callback. Without this guard, rapid interactions would flood your consumer with redundant onActive() calls.
Step 5 — Wire it up in a background service
class ContentService : Service(), UserActivityService.ActivityCallback {
private lateinit var inactivityDetector: InactivityDetector
override fun onCreate() {
super.onCreate()
inactivityDetector = InactivityDetector(
timeoutSeconds = 20,
callback = object : InactivityDetector.Callback {
override fun onInactive() {
showContent()
}
override fun onActive() {
hideContent()
}
}
)
UserActivityService.setActivityCallback(this)
inactivityDetector.start()
}
override fun onUserActivity() {
inactivityDetector.onUserActivity()
}
override fun onDestroy() {
super.onDestroy()
UserActivityService.setActivityCallback(null)
inactivityDetector.stop()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun showContent() { /* your logic */ }
private fun hideContent() { /* your logic */ }
}
The setActivityCallback(null) call in onDestroy() is not optional. The AccessibilityService and your ContentService have independent lifecycles managed by Android. If ContentService is destroyed while UserActivityService is still running, the stale reference will cause a crash or silent misbehavior when the next event fires.
Step 6 — Handle screen on/off events
AccessibilityService only delivers events when the screen is active and the device is being used. If the screen turns off, you stop receiving events — but the InactivityDetector is still counting. You need to handle screen state separately.
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_ON -> inactivityDetector.onUserActivity()
Intent.ACTION_USER_PRESENT -> inactivityDetector.onUserActivity() // after unlock
Intent.ACTION_SCREEN_OFF -> hideContent()
}
}
}
Register in onCreate():
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_USER_PRESENT)
}
registerReceiver(screenReceiver, filter)
Unregister in onDestroy():
unregisterReceiver(screenReceiver)
ACTION_USER_PRESENT fires after the lock screen is dismissed, which is different from ACTION_SCREEN_ON. Always handle both if your app needs to react correctly to device unlock.
Step 7 — Check if the service is enabled and guide the user
AccessibilityService requires the user to manually enable it in Settings → Accessibility. There is no runtime permission dialog — you must redirect them yourself.
fun ensureAccessibilityEnabled(activity: Activity) {
if (UserActivityService.isServiceEnabled(activity)) return
AlertDialog.Builder(activity)
.setTitle("Enable accessibility access")
.setMessage(
"This app needs accessibility permission to detect screen idle state. " +
"Tap 'Open Settings', find this app in the list, and enable it."
)
.setPositiveButton("Open Settings") { _, _ ->
activity.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
.setNegativeButton("Not now", null)
.show()
}
Do not cache the result of isServiceEnabled(). The user can revoke accessibility access at any point from Settings, without any callback to your app. Re-check it each time your service starts, and handle gracefully the case where it gets disabled while your service is running.
Common pitfalls
The service is declared but receives no events: The user has not enabled it in Settings. Your isServiceEnabled() check should catch this on startup. There is no workaround for the manual step — it is an intentional Android security requirement.
Events stop arriving after some time: Android's battery optimization can kill background services. For anything that needs to run continuously, make your service a foreground service with a persistent notification.
onAccessibilityEvent is called on every single interaction across the whole system: That is expected behavior. The InactivityDetector handles the debouncing — do not try to filter events in onAccessibilityEvent unless you have a specific reason.
You see a crash after ContentService is destroyed: You forgot to call setActivityCallback(null) in onDestroy(). The AccessibilityService held a reference to your destroyed object and tried to call a method on it.
Conclusion
AccessibilityService solves a real problem that has no clean alternative in the Android SDK: knowing that the user is interacting with the device from a background service, regardless of which app is in the foreground.
The architecture is clean once you have the three pieces in place: the AccessibilityService listens to system-wide events and forwards them via a callback, the InactivityDetector manages the timeout state machine, and your service reacts to onInactive() and onActive(). Each piece has one job, and the separation makes it easy to test each in isolation.
This pattern works well for kiosk apps, digital signage players, ambient display apps, and any use case where you need to react to user presence without controlling the foreground. If that sounds like your situation, this is the right approach.
If you found this useful or ran into something I did not cover, drop a comment below.
Top comments (0)