There's a scam that keeps happening: a fraudster calls the victim, pretends to be from their bank, and walks them through the banking app step by step. No password was cracked. The victim makes the transfer themselves, convinced they're "cancelling a suspicious purchase."
From the app's point of view, everything looks legitimate: the user is logged in, the device is known, and the account owner is performing the action. But there's one signal apps rarely look at: the user is on the phone with someone right now.
In this post I'll walk through how I built expo-call, an Expo module that exposes that signal through a single hook.
const inCall = useCall()What it does
The hook returns the current call state and updates in real time:
idle: no call
ringing: incoming call
active: call in progress
With that, your app can decide what to do: show a warning when it opens, require extra confirmation on sensitive operations, delay a transfer, and so on.
One important note: being on a call doesn't mean fraud is happening. The module doesn't judge anything. It only reports a risk condition. How to respond is a product decision.
Module structure
I used an Expo local module, created with:
npx create-expo-module@latest --localI selected both the Function option (to query the current state) and the Event option (to be notified when it changes). The module lives in modules/expo-call inside the app itself, with separate native code per platform and a TypeScript layer on top.
Android: AudioManager
On Android, call state can be read from the system audio mode (AudioManager.mode), which takes different values for ringing, an ongoing call, and VoIP communication. A detail worth exploring: carrier calls and messaging-app calls use different modes, so "being on a call" depends on which cases you want to cover.
private fun currentState(): String = when (audioManager.mode) {
AudioManager.MODE_IN_CALL,
AudioManager.MODE_IN_COMMUNICATION -> "active"
AudioManager.MODE_RINGTONE -> "ringing"
else -> "idle"
}
private fun emitIfChanged() {
val state = currentState()
if (state != lastState) {
lastState = state
sendEvent("onCallStateChange", mapOf("state" to state))
}
}
override fun definition() = ModuleDefinition {
Name("ExpoCall")
Events("onCallStateChange")
Function("getCallState") {
currentState()
}
OnStartObserving {
lastState = currentState()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val listener = AudioManager.OnModeChangedListener { emitIfChanged() }
modeListener = listener
audioManager.addOnModeChangedListener(
appContext.reactContext!!.mainExecutor,
listener
)
} else {
handler.post(poller)
}
}
OnStopObserving {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
modeListener?.let { audioManager.removeOnModeChangedListener(it) }
modeListener = null
} else {
handler.removeCallbacks(poller)
}
}
}iOS: CXCallObserver
On iOS, I used CXCallObserver from CallKit, with a delegate that fires on every call state change.
private class CallObserverDelegate: NSObject, CXCallObserverDelegate {
private let onChange: () -> Void
init(onChange: @escaping () -> Void) {
self.onChange = onChange
}
func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
onChange()
}
}
private let callObserver = CXCallObserver()
private var delegate: CallObserverDelegate?
private var lastState: String?
private func currentState() -> String {
let active = callObserver.calls.filter { !$0.hasEnded }
if active.contains(where: { $0.hasConnected || $0.isOutgoing }) {
return "active"
}
if !active.isEmpty {
return "ringing"
}
return "idle"
}
private func emitIfChanged() {
let state = currentState()
if state != lastState {
lastState = state
sendEvent("onCallStateChange", ["state": state])
}
}
public func definition() -> ModuleDefinition {
Name("ExpoCall")
Events("onCallStateChange")
Function("getCallState") { () -> String in
return self.currentState()
}
OnStartObserving {
self.lastState = self.currentState()
let observerDelegate = CallObserverDelegate { [weak self] in
self?.emitIfChanged()
}
self.delegate = observerDelegate
self.callObserver.setDelegate(observerDelegate, queue: nil)
}
OnStopObserving {
self.callObserver.setDelegate(nil, queue: nil)
self.delegate = nil
}
}iOS is more restrictive about what it exposes. CallKit only sees calls that go through it, so it's worth being upfront about what the module does and doesn't detect.
Unifying both platforms
The main design work was turning two different APIs into one small contract: three states, one event, one hook. For the consumer of the module, the platform differences disappear.
import { useEffect, useState } from 'react';
import type { CallState } from '../../modules/expo-call/src/ExpoCall.types';
import ExpoCallModule from '../../modules/expo-call/src/ExpoCallModule';
export function useCall() {
const [state, setState] = useState<CallState>(() =>
ExpoCallModule.getCallState(),
);
useEffect(() => {
const subscription = ExpoCallModule.addListener(
'onCallStateChange',
({ state }) => setState(state),
);
return () => subscription.remove();
}, []);
return state;
}How I tested it
Emulators don't work for this. I tested on physical devices, making and receiving real calls.
Limitations
- It detects a risk condition, not fraud
- False positives: the user may be on a perfectly legitimate call
- It doesn't replace other backend anti-fraud layers
Conclusion
App security isn't just authentication. Context signals like "this user is on a call right now" can significantly change what an app should do at that moment. The code is open source, so feel free to try it, open issues, or suggest improvements: