DEV Community

ReClaim
ReClaim

Posted on AI-assisted

Unlock challenges that fail when you look away: attention-gated friction in Jetpack Compose

We're building ReClaim, a minimalist Android launcher that's on Google Play. One of its features is hiding apps, and hiding turned out to be a design problem more than a code problem. If a hidden app opens with one tap from search, it isn't really hidden. If it can never be opened, people uninstall the launcher.

So hiding has levels. Here they are, straight from the dialog the user picks from:

HideLevelRow("Simple", "Hidden from the launcher.", 1, onPick)
HideLevelRow("Medium", "Solve a quick challenge to open.", 2, onPick)
HideLevelRow("Maximum", "Solve a harder challenge to open.", 3, onPick)
HideLevelRow("Extreme", "Eyes on the screen the whole time.", 4, onPick)
Enter fullscreen mode Exit fullscreen mode

This post is about how the challenges behind levels 2 to 4 are built in Jetpack Compose, and mostly about level 4, where the rule is "you fail if your attention leaves the screen." There's no camera and no eye tracking involved. Each challenge is designed so that looking away makes you lose. Stack: Kotlin, Jetpack Compose (BOM 2024.12.01), Hilt, minSdk 26, targetSdk 36. Snippets are trimmed from the real code; // ... marks cuts.

1. Challenge types are data, and levels pick from them

Every challenge is an enum entry, and each level offers a different subset:

enum class ChallengeType(val label: String) {
    MATH("Math problems"),
    TYPED_PHRASE("Type a phrase"),
    MEMORY_SEQUENCE("Memory sequence"),
    HOLD_TO_CONFIRM("Hold to confirm"),
    SEQUENTIAL_CONFIRMATIONS("Multiple confirmations"),
    REFLECTION("Reason for opening"),
    FOLLOW("Follow the circle"),
    FIND("Find the shape"),
    WATCH("Watch the words"),
}

fun challengeTypesForLevel(level: Int): List<ChallengeType> = when (level) {
    2 -> listOf(MATH, TYPED_PHRASE, MEMORY_SEQUENCE, HOLD_TO_CONFIRM, REFLECTION)
    3 -> listOf(MATH, TYPED_PHRASE, MEMORY_SEQUENCE, HOLD_TO_CONFIRM, REFLECTION,
                SEQUENTIAL_CONFIRMATIONS)
    4 -> listOf(FOLLOW, FIND, WATCH)
    else -> emptyList()
}
Enter fullscreen mode Exit fullscreen mode

Levels 2 and 3 share most types. Level 3 just changes the defaults. Level 4 is a separate set on purpose, and the comment above that function says why: Follow, Find and Watch "are not harder versions of the lower challenges, and a hold is intentionally absent because a finger can stay down while the person looks away."

2. Per-app overrides with null meaning "use the default"

People wanted to tune a challenge per app, for example "two minutes of holding for this one app, ten seconds for the rest." Instead of copying level defaults into every row, the config stores only what the user touched:

data class HideChallengeConfig(
    val holdSeconds: Int? = null,
    val mathProblemCount: Int? = null,
    val mathHard: Boolean? = null,
    val sequenceLength: Int? = null,
    val reflectionMinWords: Int? = null,
    val phraseHard: Boolean? = null,
    val followSeconds: Int? = null,
    val findRounds: Int? = null,
    val watchRounds: Int? = null,
)

fun HideChallengeConfig.effectiveHoldSeconds(level: Int): Int =
    holdSeconds ?: if (level >= 3) 120 else 10
fun HideChallengeConfig.effectiveMathProblemCount(level: Int): Int =
    mathProblemCount ?: if (level >= 3) 3 else 1
fun HideChallengeConfig.effectiveFollowSeconds(): Int = followSeconds ?: 20
// ...
Enter fullscreen mode Exit fullscreen mode

The upside: moving an app from Medium to Maximum makes every untouched knob harder at once, while anything the user set by hand stays put. A single router composable then reads the saved type and hands each dialog its effective values:

when (app.challengeType) {
    ChallengeType.MATH, null -> ChallengeDialog(
        problemCount = config.effectiveMathProblemCount(level),
        hard = config.effectiveMathHard(level),
        onSuccess = onSuccess, onDismiss = onDismiss,
    )
    ChallengeType.FOLLOW -> FollowChallengeDialog(
        seconds = config.effectiveFollowSeconds(),
        onSuccess = onSuccess, onDismiss = onDismiss,
    )
    // ... one branch per type
}
Enter fullscreen mode Exit fullscreen mode

Note the null in the first branch: an app hidden before challenge types existed falls back to math, so old data never crashes the router.

3. Levels 2 and 3: friction against autopilot

The lower levels aim at one thing: you can't complete them without thinking for a second. Math draws random operands, with bigger numbers and multiplication only when hard. Typed phrase picks a sentence, and at level 3 the sentences carry punctuation ("This is intentional; I have thought about it.") that has to match exactly. Memory sequence shows colored dots for 2.5 seconds, and any wrong tap clears your input. Reflection asks why you want to open the app and counts words with text.trim().split(Regex("\\s+")), requiring 10 at level 3.

Sequential confirmations, the level 3-only type, is my favorite small trick. It's four steps, and each one is a different action so muscle memory can't carry you through: a button, then a button in a different spot, then a switch that has to be turned on before "Next" is enabled, then a two-second hold.

when (step) {
    0 -> Button(onClick = { step = 1 }) { Text("Continue") }
    1 -> Row(horizontalArrangement = Arrangement.End) {
        Button(onClick = { step = 2 }) { Text("I'm sure") }
    }
    2 -> {
        Switch(checked = toggleChecked, onCheckedChange = { toggleChecked = it })
        Button(onClick = { step = 3 }, enabled = toggleChecked) { Text("Next") }
    }
    else -> HoldButton(targetMillis = 2_000L, label = "Hold to open", onComplete = onSuccess)
}
Enter fullscreen mode Exit fullscreen mode

The weakness of all of these is that they test effort, not attention. You can hold a button for two minutes while watching TV. That's what level 4 is for.

4. Follow the circle: a game loop inside LaunchedEffect

Follow asks you to keep a finger on a circle for 20 seconds by default. The circle drifts, so a parked finger loses it, and every couple of seconds it jumps to a new spot. The jump is announced by a small mark that exists only on screen, so you have to be looking to know where to go.

The motion lives in a plain Kotlin class, CircleChase, with three phases:

private enum class ChasePhase { DRIFT, PREVIEW, GRACE }

private const val PREVIEW_MS = 640f
private const val GRACE_MS = 700f
private const val FIRST_JUMP_MS = 2_200f
Enter fullscreen mode Exit fullscreen mode

In DRIFT the circle steers toward a random heading that changes every 550 to 1,250 ms and bounces off the edges. A jump countdown only runs while your finger is actually on the circle (tracking), so the jump is a response to good tracking, not a random punishment. When it hits zero, PREVIEW shows the mark for 640 ms and freezes the circle, so glancing at the mark doesn't by itself knock your finger off. Then the circle teleports to the mark and GRACE gives you 700 ms to land on it. Miss the landing and your earned time resets.

Choosing where to jump is a bounded random search. It tries 24 points and takes the first one that's far enough to require moving, but not so far that it's unfair:

repeat(24) {
    val px = margin + random.nextFloat() * (width - 2 * margin).coerceAtLeast(0f)
    val py = margin + random.nextFloat() * (height - 2 * margin).coerceAtLeast(0f)
    val distance = hypot(px - x, py - y)
    if (distance in minJump..ceiling) {
        previewX = px; previewY = py
        return true
    }
    // ... otherwise remember the farthest legal candidate
}
Enter fullscreen mode Exit fullscreen mode

minJump and maxJump are 108 dp and 170 dp, converted with LocalDensity, and if the field is too small for any legal jump the function returns false and the countdown simply restarts.

The loop itself runs inside a LaunchedEffect driven by withFrameNanos, which suspends until the next frame and gives you its timestamp:

LaunchedEffect(boxSize, speedPxPerMs, contactPx, totalMs) {
    if (boxSize.width < 8 || boxSize.height < 8) return@LaunchedEffect
    val model = CircleChase(/* ... */)
    var lastFrame = 0L
    var done = false
    while (!done) {
        val frame = withFrameNanos { it }
        if (lastFrame == 0L) { lastFrame = frame; continue }
        val dt = ((frame - lastFrame) / 1_000_000f).coerceIn(0f, 32f)
        lastFrame = frame
        val finger = pointer.value
        val wasOn = finger != null && model.contains(finger.x, finger.y, contactPx)
        model.advance(dt, speedPxPerMs, turnPerMs,
            tracking = wasOn && model.phase == ChasePhase.DRIFT)
        // ... add dt to progress while on the circle, reset on a slip
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here. First, dt is clamped to 32 ms. If the app stalls (a GC pause, or the dialog briefly losing focus), the circle doesn't leap across the screen on the next frame. Second, onSuccess and onSlip are read through rememberUpdatedState. The Compose side-effects guide describes exactly this case: a value used inside an effect that shouldn't restart the effect when it changes. Restarting this effect would throw away the player's progress.

Touch tracking is a raw pointer loop with awaitEachGesture, which the gestures guide recommends over a hand-written while (true) because it restarts once all pointers are lifted:

.pointerInput(Unit) {
    awaitEachGesture {
        val down = awaitFirstDown(requireUnconsumed = false)
        pointer.value = down.position
        down.consume()
        try {
            while (true) {
                val event = awaitPointerEvent()
                val change = event.changes.firstOrNull { it.id == down.id } ?: break
                if (!change.pressed) break
                pointer.value = change.position
                change.consume()
            }
        } finally {
            pointer.value = null
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It follows only the pointer that started the gesture (it.id == down.id), so a second finger can't take over, and the finally clears the position on every exit. The hit test uses a 62 dp contact radius while the circle is drawn at 34 dp. Being slightly generous with fat fingers made the challenge feel fair without making it easy.

5. Find the shape: a conjunction search

Find shows a 5×5 grid of outlines with a small gap in each, and asks you to tap, say, "the triangle with a gap on the left." The board is built so that no single feature gives the answer away:

private fun newFindBoard(random: Random): FindBoard {
    val shape = MarkShape.entries.random(random)
    val gap = GapSide.entries.random(random)
    val target = MarkSpec(shape, gap)
    val otherGaps = GapSide.entries.filter { it != gap }
    val otherShapes = MarkShape.entries.filter { it != shape }
    val cells = ArrayList<FindCell>(FIND_GRID * FIND_GRID)
    cells += FindCell(target, true)
    repeat(6) {
        cells += FindCell(MarkSpec(shape, otherGaps[0]), false)
        cells += FindCell(MarkSpec(shape, otherGaps[1]), false)
        cells += FindCell(MarkSpec(otherShapes[0], gap), false)
        cells += FindCell(MarkSpec(otherShapes[1], gap), false)
    }
    cells.shuffle(random)
    return FindBoard(target, cells)
}
Enter fullscreen mode Exit fullscreen mode

Twelve distractors share the target's shape and twelve share its gap side, so you can't scan for "the only triangle" or "the only left gap." You have to check each cell for both. The glyphs are drawn on a Canvas: circles with drawArc and a 42° missing wedge, and squares and triangles with a Path that skips 18% of one edge. The default is eight boards in a row, and one wrong tap generates a fresh board and sends you back to the first. There's also a 220 ms input lock after each new board, so a tap already on its way from the previous board doesn't count against the next one.

6. Watch the words: recognition isn't enough

Watch flashes eight words, one at a time, for 520 ms each with an 80 ms gap, all from a LaunchedEffect with delay. Then it asks what came right after one of them:

private fun newWatchRound(random: Random): WatchRound {
    val words = WATCH_BANK.shuffled(random).take(8)
    val probe = random.nextInt(0, words.lastIndex)
    val distractors = words
        .filterIndexed { index, _ -> index != probe && index != probe + 1 }
        .shuffled(random)
        .take(3)
    val choices = (distractors + words[probe + 1]).shuffled(random)
    return WatchRound(words, probe, choices)
}
Enter fullscreen mode Exit fullscreen mode

The important line is the distractor filter. Every wrong choice also appeared in the list, so half-noticing the words while looking at something else doesn't help. You have to have seen the order. nextInt(0, words.lastIndex) excludes the last word as the probe, since nothing comes after it. Four rounds by default, and a miss starts over.

7. Don't ask twice: a ten-second grace window

Once the challenges existed, a new annoyance showed up. A hidden app can also have a reminder timer, and opening it could hit two gates back to back: the challenge when you unlock it, then the reminder prompt, which also checks the challenge. So there's a tiny in-memory store keyed by package:

@Singleton
class HiddenChallengeGraceStore @Inject constructor() {
    private val solvedAt = mutableMapOf<String, Long>()

    @Synchronized
    fun markSolved(packageName: String) {
        solvedAt[packageName] = SystemClock.elapsedRealtime()
    }

    @Synchronized
    fun isRecentlySolved(packageName: String): Boolean {
        val at = solvedAt[packageName] ?: return false
        return SystemClock.elapsedRealtime() - at < GRACE_MILLIS
    }

    private companion object { const val GRACE_MILLIS = 10_000L }
}
Enter fullscreen mode Exit fullscreen mode

It uses SystemClock.elapsedRealtime(), time since boot, so changing the phone's clock can't stretch the window. Ten seconds covers "the same open" and nothing more. When a reminder later says time's up, minutes have passed, so continuing asks for the challenge again. It's deliberately not persisted, since every gate runs in the same process.

8. The bug that taught us "a challenge is its own gate"

The launch logic used to tuck the challenge inside the reminder flow. Hiding an app doesn't remove it from Home or from a folder, so a level 2 or 3 app could still be tapped there, and every reason to skip the reminder prompt (a session already running, or no reminder set) skipped the challenge too. The app just opened. The fix was to make the challenge its own branch, checked first:

when {
    blockRepository.isBlocked(app.packageName) ->
        _events.emit(LauncherUiEvent.ShowBlocked(app.packageName))
    app.hideLevel >= 2 && !hiddenChallengeGraceStore.isRecentlySolved(app.packageName) ->
        _events.emit(LauncherUiEvent.ShowHiddenChallenge(app))
    reminderRepository.hasReminder(app.packageName) &&
        !reminderSessionStore.isActive(app.packageName) ->
        _events.emit(LauncherUiEvent.ShowReminderPrompt(app))
    else -> appRepository.launch(app)
}
Enter fullscreen mode Exit fullscreen mode

The lesson generalizes: if a check guards access, give it its own branch at every entry point instead of hoping it rides along with another check.

What we'd tell another team building friction

  • Separate effort from attention. Holds and long phrases cost time; drifting targets and order questions cost focus.
  • Store per-item overrides as nullable fields and resolve defaults at read time.
  • Run animation as a frame loop in LaunchedEffect with a clamped dt, and read callbacks through rememberUpdatedState.
  • Track one pointer by id, and clear its state in finally.
  • Build distractors from the same features as the target, so nothing pops out.
  • Use elapsedRealtime() for short time windows. The hold button still measures with System.currentTimeMillis(), which we plan to move over for the same reason.

If you want to try the levels yourself, ReClaim is on Google Play, and our guide to hiding apps on Android covers the non-code side.

This post was drafted with AI assistance and reviewed by the ReClaim team.

Top comments (0)