Handle a runtime threat signal in three steps. Turn the observation into one policy decision for the current user flow. Apply any visible change on the main thread, in a component that outlives the current screen. Then let your backend decide whether the sensitive request proceeds, so a retry or a modified client cannot repeat or skip the check. Otukan provides runtime threat signals and applies the response policy you set for each protected flow. This guide covers the code around that: lifecycle, threading, idempotency and session binding, scoped separately for Android and iOS.
For the event contract, data handling and analytics side, see runtime threat signals best practices.
Map each signal to one decision
Write an internal mapping from each documented observation to a business question. May this session start a transfer? Change account recovery details? Only view non-sensitive content? Keep the raw observation and the answer apart. The same observation can justify different actions in different flows.
| Question | What to settle | Check |
|---|---|---|
| Meaning | Which condition the signal represents | A reviewer can tell observation from interpretation |
| Scope | Platform, framework and release that produce it | Android and iOS have separate entries |
| Repetition | Whether it can recur, arrive late or out of order | Repeated input never repeats a sensitive action |
| Availability | How unsupported, unavailable and failed checks look | None of them becomes a clean result |
| Data | Which attributes the decision needs | Logs exclude credentials and user content |
Route every observation to a single decision point, such as one policy object or service in your app. Define how an existing restriction changes when stronger evidence arrives, and what evidence allows recovery. Two screens that each react on their own will disagree sooner or later.
Android: lifecycle-aware handling
Hold state outside the activity
An activity is destroyed and recreated on configuration changes such as rotation, and the system can kill a background process to reclaim memory. A receiver tied to one activity therefore has a shorter life than the decision it triggers.
- Keep the current decision in a
ViewModelexposed as aStateFloworLiveData. It survives configuration changes. - Use
SavedStateHandlefor the small part of state that must survive process death, such as “this flow is restricted”. Re-evaluate on restore rather than trusting old evidence. - For app-wide state, observe
ProcessLifecycleOwnerto know when the whole app moves to the foreground or background.
Collect on the main thread, only while visible
Views must be touched from the main thread. Collect the decision with lifecycleScope and repeatOnLifecycle(Lifecycle.State.STARTED), or collectAsStateWithLifecycle() in Jetpack Compose. Collection then runs on Dispatchers.Main, stops when the screen is hidden and restarts with the latest state when it returns. A restriction that arrived in the background is applied when the user comes back, not lost.
Never block the main thread while waiting for a remote decision. Do network and disk work on Dispatchers.IO and post only the result back. A blocked main thread produces an “Application Not Responding” dialog, which looks like a crash to the user.
iOS: main actor and scene lifecycle
Update the interface on the main actor
UIKit and SwiftUI views must be updated on the main thread. With Swift concurrency, mark the type that publishes the decision @MainActor, or hop to it with await MainActor.run { … } before changing UI state. In older code, dispatch to DispatchQueue.main. Keep evaluation and network calls off the main actor and publish only the outcome.
An ObservableObject or @Observable model owned above the current view keeps the decision when a view is rebuilt or a sheet is dismissed.
Follow the scene and app lifecycle
Since iOS 13, an app can have several scenes, and each moves through foreground and background on its own. React in sceneWillEnterForeground(_:) and sceneDidEnterBackground(_:) on your UISceneDelegate, or watch the scenePhase environment value in SwiftUI (.active, .inactive, .background).
- Re-check pending decisions when a scene becomes active. Evidence gathered before suspension may be stale.
- Hide sensitive content when the scene leaves
.active, so the app switcher snapshot does not show it. - Assume the system can terminate a suspended app without notice. Persist only what the restored flow needs and re-evaluate on launch.
Bind decisions to the session and make them idempotent
Session binding
Associate each observation with the current app session and the attempted operation. On logout, cancel pending UI work and clear queued decisions so they never cross into the next user’s session. On account switch, start from a fresh state. An old result must not attach itself to whoever signs in next.
Idempotency
Serialise changes to the state that controls a sensitive action. Suppose a user taps twice while a signal arrives: neither the repeated observation nor a network retry should submit the payment again. Send an idempotency key with each sensitive request and let the server return the original outcome for a repeat. On reconnect, discard decisions that no longer apply to the operation. An offline state is a transport condition, not evidence of a hostile device.
Server enforcement
The backend verifies the evidence it requires and authorises the request. A local warning, a disabled button or a client-supplied “passed” flag never replaces that check. Bind platform evidence such as Play Integrity on Android or App Attest on iOS to the request, as described in how to verify mobile app attestation.
Give the user a way forward
Define a recovery route before choosing a restrictive action. Keep support reachable where your risk policy allows. Explain which action the user cannot complete, without exposing detection internals. Do not ask users to turn off security or accessibility tools as a routine workaround.
Test handling and detection separately
Start with a synthetic input delivered to a test seam in a debug build. This isolates your handling from detection. Keep the seam out of release builds. Then run an authorised detector test in a controlled lab; the two answer different questions.
| Test case | Expected result to write down first |
|---|---|
| Authorised positive detector case | Documented signal and the intended flow restriction |
| Same journey on a legitimate device | Normal progress, no restriction |
| Duplicate or delayed input | No duplicate action, no decision applied to a new session |
| Rotation, backgrounding or process death | Consistent state, no update to a dead screen |
| Lost connection or verifier outage | Named fallback, bounded retry, clear recovery |
| Malformed or unsupported input | Explicit handling path; absence never means safe |
Repeat install, update, sign-in and the sensitive flow on each platform in scope, with release builds. Record device and OS versions, app and protection revisions, input, expected and actual response, and reviewer.
- Android and iOS observations and expected outcomes are recorded separately.
- UI changes happen on the main thread and survive rotation, backgrounding and restore.
- Logout, retry and recovery cannot repeat a transaction.
- The server enforces every sensitive operation on its own evidence.
For product scope, see runtime self-protection, runtime threat signals and threat monitoring.
Further reading
- Handling lifecycles with lifecycle-aware components — Android Developers
- The activity lifecycle — Android Developers
- Use Kotlin coroutines with lifecycle-aware components — Android Developers
- Managing your app’s life cycle — Apple Developer Documentation
- ScenePhase — Apple Developer Documentation
- MainActor — Apple Developer Documentation