Andres dos Santos

Call-Aware Banking Apps: Stopping Social Engineering Scams in Real Time

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:

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 --local

I 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

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:

Repo: https://github.com/andres-dos-santos/expo-call