DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

A button on the lock screen must not open a chat: an unlock trampoline on Android

QuickDial puts a small chat button next to a normal phone call, so you can jump from the call to a chat with that number without saving it. Calls often come in while the phone is locked, so in QuickDial's source the button can also be tapped on the lock screen. That raised a question I had to answer before shipping it: what happens to the chat when somebody else is holding your phone?

The answer had to be "nothing opens until the phone is unlocked". This post is about the small, invisible activity that enforces that.

Disclosure: I'm the developer of QuickDial, and this post is published from my account. It was written and published by an AI agent working on my behalf. QuickDial is an independent app, not affiliated with WhatsApp LLC or Meta.

The risk

A chat screen shows message history, names, photos and documents. A button that is reachable on a locked phone and opens that screen directly would turn a convenience into a privacy leak: anyone who picks up the phone during a call could read the owner's conversations.

So the rule is simple to state:

  • The button may be visible on the lock screen.
  • The chat may only open after the user unlocks with their PIN, pattern or biometric.
  • If the unlock is cancelled or fails, nothing opens. Not the chat, not the app, not a half-loaded screen.

Decide at the call site

The service that handles the tap checks the keyguard first. If the phone is unlocked, the chat intent starts as usual. If it is locked, the same intent is wrapped and handed to a dedicated activity:

private fun startBehindUnlock(next: Intent) {
    val keyguard = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
    startActivity(
        if (keyguard.isKeyguardLocked) {
            UnlockThenOpenActivity.wrap(this, next)
        } else {
            next.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        },
    )
}
Enter fullscreen mode Exit fullscreen mode

Every path that opens a chat from the call button goes through this one function, including the screen where the user picks one of several numbers for a contact. One choke point means one place to get right.

The trampoline

UnlockThenOpenActivity has no layout of its own. It carries the real intent as an extra, shows itself over the lock screen, asks the system to dismiss the keyguard, and starts the real intent only from the success callback:

class UnlockThenOpenActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val next = nextIntent(intent) ?: run { finish(); return }

        val keyguard = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
        if (!keyguard.isKeyguardLocked) { launch(next); return }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) setShowWhenLocked(true)
        keyguard.requestDismissKeyguard(this, object : KeyguardManager.KeyguardDismissCallback() {
            override fun onDismissSucceeded() = launch(next)
            override fun onDismissCancelled() = finish()
            override fun onDismissError() = finish()
        })
    }

    private fun launch(next: Intent) {
        runCatching { startActivity(next) }
        finish()
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details carry the weight here:

The real intent is started in exactly one place. launch() is reached only when the phone was not locked to begin with, or from onDismissSucceeded(). Cancelled and error both call finish() and nothing else. When you review this kind of code, look for any other startActivity and treat it as a bug.

It re-checks the lock. The caller already checked, but by the time the activity is created the user may have unlocked with a fingerprint. Checking again avoids showing an unlock prompt for a phone that is already open.

requestDismissKeyguard lets the system own the unlock. The app never sees the PIN or the biometric. It shows the standard prompt, and the callback reports the result. Secure lock screens get the user's own credential check; a swipe-only lock screen just goes away.

It finishes on every path. A trampoline that stays alive after doing its job leaves an empty, see-through activity in the task, which looks like a frozen screen to the user.

The manifest entry matters as much as the code

<activity
    android:name=".ui.unlock.UnlockThenOpenActivity"
    android:exported="false"
    android:excludeFromRecents="true"
    android:noHistory="true"
    android:taskAffinity=""
    android:theme="@style/Theme.QuickDialCallBridge.Chooser" />
Enter fullscreen mode Exit fullscreen mode
  • exported="false": no other app can send an intent to it and use it as a way to open arbitrary intents over the lock screen. This is the most important line in the file. A trampoline that takes "the next intent" as an extra must never be reachable from outside your app.
  • excludeFromRecents and noHistory: it never shows up in the recent-apps list and is not kept on the back stack, so pressing Back after the chat opens does not land on an invisible screen.
  • taskAffinity="": it does not join the app's main task, so unlocking does not pull the app's home screen forward behind the chat.
  • A translucent theme: the user sees the lock screen and then the system prompt, not a blank app window.

Reading the wrapped intent

Since Android 13 the type-safe getParcelableExtra(name, Class) is the right call; on older versions only the deprecated one exists:

private fun nextIntent(intent: Intent?): Intent? =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        intent?.getParcelableExtra(EXTRA_NEXT, Intent::class.java)
    } else {
        @Suppress("DEPRECATION")
        intent?.getParcelableExtra(EXTRA_NEXT)
    }
Enter fullscreen mode Exit fullscreen mode

A missing extra finishes the activity at once. "Nothing to open" is a normal outcome, not a crash.

Version notes

The app's minimum SDK is 26, which is where requestDismissKeyguard arrived, so the API is always there. setShowWhenLocked arrived one release later, in 8.1 (API 27). On 8.0 exactly, the older window flag FLAG_SHOW_WHEN_LOCKED is the equivalent, and that version is worth a test on a real device if you support it.

Test it like an attacker would

Things I check by hand on a locked phone:

  1. Tap the button, cancel the prompt: nothing opens, and the lock screen is still there.
  2. Tap the button, fail the fingerprint several times until it falls back to PIN, then cancel: nothing opens.
  3. Tap the button, unlock: the chat opens; Back goes to wherever the user was before, not to an empty screen.
  4. Open Recents afterwards: no blank entry.
  5. Phone already unlocked: no prompt at all, the chat opens straight away.

Takeaways

  • Showing a control on the lock screen and opening content from it are two separate permissions. Grant the first, gate the second.
  • Route every "open content" path through one function that checks the keyguard.
  • Let requestDismissKeyguard own the unlock, and start the real intent only from onDismissSucceeded().
  • A trampoline that forwards an intent must be exported="false".
  • Finish on every path, and keep the trampoline out of Recents and the back stack.

QuickDial is on Google Play as "QuickDial: Direct Chat & Call": https://play.google.com/store/apps/details?id=com.ashwar.quickdialbridge&utm_source=devto&utm_medium=article&utm_campaign=2026-w45-unlock. It works with WhatsApp and WhatsApp Business; independent app, not affiliated with WhatsApp LLC or Meta.

Top comments (0)