A

aegis_bip

Passive behavioural-biometric intelligence for mobile banking & fintech apps.

v1.1.0 Flutter ≥ 3.16 Dart ≥ 3.2 License: Proprietary Publisher: Funkash Private distribution — git ref only
SDK for

Showing the Flutter SDK. Native Android and React Native now ship too — same nine-plus-one signal taxonomy, same checkpoint contract, same backend.

What it does

aegis_bip instruments a mobile app so it can tell a genuine, present user apart from someone else operating the session — a stolen device, a remote-access scam, a bot — without ever asking the user to do anything extra. It passively observes how the app is used (touch pressure and rhythm, typing cadence, navigation pattern, how long screens take to react to, device motion, session timing, environment and security signals) and turns that into a continuous behavioural profile. At transaction time, a single checkpoint() call scores the current session against that profile and returns a verdict your app acts on: allow, step up, or block.

Backend contract is framework-agnostic. Every client SDK — Flutter and native Android today, React Native once built — talks the same event schema and checkpoint API described in DATA_DICTIONARY.md. Switching the SDK above shows how each platform's client code differs; the signals collected and the verdicts returned are identical.

Quick start

lib/main.dart
import 'package:aegis_bip/aegis_bip.dart';

await AegisBIP.init(AegisConfig(
  clientId: 'YOUR_CLIENT_ID',
  backendUrl: 'https://your-backend.example.com',
  checkpointUrl: 'https://your-backend.example.com/checkpoint',
  useProtobuf: false,
));
Application.kt
AegisBip.init(this, AegisConfig(
  clientId = "YOUR_CLIENT_ID",
  clientVersion = "1.0.0",
  backendUrl = "https://your-backend.example.com/v1/sessions",
  checkpointUrl = "https://your-backend.example.com/v1/checkpoint",
  authToken = BuildConfig.AEGIS_TOKEN,
))
AegisBip.attachToApplication(this) // Category A + C, one call
App.tsx
import { AegisBip, AegisBipCaptureRoot } from 'react-native-aegis-bip';

await AegisBip.init({
  clientId: 'YOUR_CLIENT_ID',
  clientVersion: '1.0.0',
  backendUrl: 'https://your-backend.example.com/v1/sessions',
  checkpointUrl: 'https://your-backend.example.com/v1/checkpoint',
  authToken: Config.AEGIS_TOKEN,
});

// Category A + C, one wrap
return <AegisBipCaptureRoot><YourAppNavigator /></AegisBipCaptureRoot>;

Signal categories

Nine categories of passive signal feed the risk model. All nine apply regardless of which client SDK collects them.

CatCategoryWhat it captures
ATouchPressure, contact size, gesture velocity and curvature
BKeystrokeInter-key timing, dwell time, correction rate
CNavigationScreen sequence, dwell per screen, back-navigation rate
DSession timingLogin latency, session duration, time-of-day pattern
EMotionAccelerometer / gyroscope signature while holding the device
FCognitiveHesitation before high-risk actions (button press latency)
GEnvironmentLocale, timezone, network type, screen metrics
HSecurityAuth method used, credential entry pattern, biometric availability
IDevice stateBattery, orientation, accessibility services active

Add the dependency

Distributed privately via git ref — not published to pub.dev.

pubspec.yaml
dependencies:
  aegis_bip:
    git:
      url: https://gitlab.funtech.dev/bip/mobile-sdk.git
      ref: v1.1.0
shell
flutter pub get

Pin the ref. Track a tag, not a branch — ref: v1.1.0 not ref: main — so a SDK release can't change your app's behaviour without a deliberate bump.

Or use the prebuilt package archive — for offline use, a CI mirror, or as the tree you push to your own private git tag: ↓ aegis-bip-flutter-1.1.0.tar.gz (297 KB) · ↓ API docs (706 KB) · verify against SHA256SUMS.txt.

pubspec.yaml (path dependency)
dependencies:
  aegis_bip:
    path: vendor/aegis_bip   # extract the archive here

Add the dependency

Distributed privately via a Gradle source dependency — the Gradle-native equivalent of pubspec's git-ref dependency. No Maven registry involved.

settings.gradle.kts
sourceControl {
  gitRepository(java.net.URI("https://gitlab.funtech.dev/bip/android-sdk.git")) {
    producesModule("com.funkash.bip:bip-android")
  }
}
app/build.gradle.kts
dependencies {
  implementation("com.funkash.bip:bip-android") {
    version { branch = "v0.1.0" }
  }
}

Pin the tag. Track a release tag, not a branch — v0.1.0 not main — so an SDK release can't change your app's behaviour without a deliberate bump. Same discipline the Flutter SDK's git-ref dependency requires.

Or use the prebuilt AAR — for offline use or a CI mirror, no Gradle source dependency required: ↓ aegis-bip-android-1.1.0.aar (841 KB) · verify against SHA256SUMS.txt.

app/build.gradle.kts
dependencies {
  implementation(files("libs/aegis-bip-android-1.1.0.aar"))
  // AAR doesn't bundle transitive deps — also add:
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:<version>")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:<version>")
  implementation("com.squareup.okhttp3:okhttp:<version>")
  implementation("com.google.protobuf:protobuf-kotlin:4.28.2")
}

Add the dependency

Distributed privately as a git-ref npm dependency — not on the npm registry, same no-registry model as both other SDKs. TurboModule-based (New Architecture only, matching React Native 0.76+'s default).

package.json
{
  "dependencies": {
    "react-native-aegis-bip": "git+https://gitlab.funtech.dev/bip/react-native-sdk.git#v0.1.0"
  }
}

It also depends on two well-known community packages for native device/sensor access — the same reuse-over-reinvention tradeoff the Flutter SDK makes with sensors_plus/ device_info_plus:

shell
npm install @react-native-async-storage/async-storage react-native-device-info
cd ios && pod install

Pin the tag. Track a release tag, not a branch — v0.1.0 not main — so an SDK release can't change your app's behaviour without a deliberate bump. Same discipline both other SDKs' dependency declarations require.

Or use the prebuilt tarball — for offline use or a CI mirror, no git access required: ↓ react-native-aegis-bip-0.1.0.tgz (159 KB) · verify against SHA256SUMS.txt.

shell
npm install ./react-native-aegis-bip-0.1.0.tgz
cd ios && pod install

Platform support

Coverage varies by OS capability — motion sensors and touch pressure aren't available everywhere.

iOS
● Full support
Android
● Full support
Web
● Partial — no motion (E)
macOS
● Not supported
Windows
● Not supported
Linux
● Not supported

Platform support

Android only — this SDK doesn't target other OSes, so there's no cross-platform matrix the way the Flutter SDK has one. What varies here is signal-category coverage, not OS.

minSdk 21 – compileSdk 35
● Supported range

Signal-category coverage

CatCategoryStatus
ATouchFull — Window.Callback interception
BKeystrokeFull — TextWatcher
CNavigationActivity-level automatic; per-destination needs a manual hook for single-Activity/Compose apps
DSession timingFull
EMotionFull — SensorManager
FCognitiveFull
GEnvironmentPartial — carrier/roaming/brightness wired; network type, VPN, battery, location not yet
HSecurityFull — including auth timing and screen-capture/remote-access detection
IDevice stateFull
JCompositeFull

Platform support

New Architecture (TurboModules) only — matches React Native 0.76+'s default; RN 0.82 removes the legacy bridge entirely, so there's nothing to configure either way.

iOS
● Full support
Android
● Full support

Signal-category coverage

CatCategoryStatus
ATouchFull — passive bubble-phase touch events, never the responder system
BKeystrokeFull — TextInput length-delta tracking; dwell time unmeasurable on a soft keyboard, same gap every SDK has
CNavigationManual recordScreenVisit() API, no hard dependency on any nav library
DSession timingFull
EMotionFull — native TurboModule streaming (accelerometer/gyroscope), not a community package
FCognitiveFull
GEnvironmentPartial — carrier/roaming/brightness wired; network type, VPN, battery, location not yet
HSecurityFull on Android; iOS has no accessibility/remote-control introspection API and hard-codes is_roaming false — same known gap as the Flutter SDK's iOS plugin
IDevice stateFull
JCompositeFull

A newcomer's walk-through

Every screen and code excerpt below is pulled from the demo app (example/lib/main.dart, "BIP Demo" powered by Funkash). Follow it top to bottom: it's the order a first-time integrator would actually wire things up in, and by the end it touches all nine signal categories.

1

App starts — wire the SDK once

no screen · wiring
main.dart
await AegisBIP.init(AegisConfig(
  clientId: TenantConfig.clientId,
  backendUrl: TenantConfig.baseUrl,
  checkpointUrl: TenantConfig.baseUrl + '/checkpoint',
  useProtobuf: false,
));

MaterialApp(
  navigatorObservers: [_aegisObserver], // = AegisBIP.instance.navigatorObserver
  builder: (context, child) => AegisTouchDetector(
    sessionManager: AegisBIP.instance.sessionManager,
    child: child!,
  ),
);
main.dart:144–224
AegisBIP.init()navigatorObserverAegisTouchDetector
2

Consent, then login

Behavioural protection consent dialog

Before anything is collected, the app asks for consent. Only on accept does a session start.

main.dart
bip.grantConsent();
bip.startSession(
  subjectId: pseudonymousSubjectId(identity, salt: _kSubjectSalt),
  userEmail: email,
);
// reported: AuthPromptShownEvent, CredentialEntryEvent, AuthCompletedEvent
main.dart:277–397
grantConsent()startSession() Cat H · SecurityCat D · Session timing
3

Home — moving around the app

BIP Demo home dashboard

Landing on the dashboard tags the current task and the whole body sits under the touch detector wired in step 1 — no extra code needed on this screen.

main.dart
bip.setTask(BipTask.browse);

return Scaffold(
  body: AegisTouchDetector(
    sessionManager: AegisBIP.instance.sessionManager,
    child: CustomScrollView(/* dashboard content */),
  ),
);
main.dart:931, 1015–1017
setTask()AegisTouchDetector Cat A · TouchCat C · Navigation
4

Filling the transfer form

New transfer screen

Every field the user types into is wrapped so keystroke cadence is captured while they fill it in.

main.dart
bip.setTask(BipTask.transfer);

AegisTextField(
  sessionManager: AegisBIP.instance.sessionManager,
  controller: _recipientCtrl,
  decoration: const InputDecoration(labelText: 'Recipient account or phone'),
);
AegisTextField(
  sessionManager: AegisBIP.instance.sessionManager,
  controller: _amountCtrl,
  decoration: const InputDecoration(labelText: 'Amount (₦)'),
);
main.dart:1802, 1976–1996
AegisTextFieldsetTask()Cat B · Keystroke
5

Confirm — the checkpoint contract

same screen as step 4
New transfer screen, send action

Tapping send is timed (hesitation is itself a signal), then the transaction is described to the SDK and scored before anything happens.

main.dart
AegisTimedAction(
  onPressed: _onSendTapped,
  child: IconButton(icon: const Icon(Icons.send), onPressed: _onSendTapped),
);

bip.setTransactionContext(amount: amount, recipient: recipient);
final score = await bip.checkpoint(
  reason: CheckpointReason.transferInitiated,
);
main.dart:1858–1897, 1960–1965
AegisTimedActionsetTransactionContext() checkpoint()Cat F · Cognitive
6

The verdict — allow, or stop the transfer here

⛔
Transfer Blocked
Irregular behaviour detected (risk 87%). This transfer has been STOPPED and flagged to your account manager.
Understood
Rendered preview — not a screenshot

The score decides everything that happens next. On block or stepUp the transfer is hard-stopped right here — the dialog on the left is exactly what _showRiskAlert shows, word for word, and the function returns before the PIN screen is ever reached. Only a clean allow continues.

main.dart
final blocked = score?.shouldBlock == true;
final stepUp  = score?.requiresStepUp == true;

if (blocked || stepUp) {
  _showRiskAlert(
    title: blocked ? 'Transfer Blocked'
                   : 'Transfer Halted — Verification Required',
    body: blocked
        ? 'Irregular behaviour detected (risk ${(score!.score * 100).toStringAsFixed(0)}%). '
          'This transfer has been STOPPED and flagged to your account manager.'
        : 'Irregular activity detected (risk ${(score!.score * 100).toStringAsFixed(0)}%). '
          'This transfer is halted and cannot proceed until your identity is verified.',
    isBlock: true, // both are a hard stop
  );
  return; // STOP — never falls through to the PIN screen
}

_sent = true;
Navigator.pushReplacement(context, MaterialPageRoute(
  builder: (_) => PinScreen(/* clean allow continues here */),
));
main.dart — _showRiskAlert()
void _showRiskAlert({required String title, required String body, required bool isBlock}) {
  final cs = Theme.of(context).colorScheme;
  showDialog(
    context: context,
    builder: (ctx) => AlertDialog(
      icon: Icon(isBlock ? Icons.block : Icons.warning_amber_rounded,
          color: isBlock ? cs.error : Colors.orange, size: 36),
      title: Text(title),
      content: Text(body),
      actions: [
        FilledButton(
          onPressed: () => Navigator.pop(ctx),
          style: isBlock ? FilledButton.styleFrom(backgroundColor: cs.error) : null,
          child: const Text('Understood'),
        ),
      ],
    ),
  );
}
main.dart:1858–1940
shouldBlockrequiresStepUp _showRiskAlert()fail-open on null
7

Logout — end the session

same home screen as step 3
BIP Demo home dashboard, logout

Ending the session is explicit — don't rely on app termination to close it out.

main.dart
AegisBIP.instance.endSession();
await FirebaseAuth.instance.signOut();
Navigator.pushAndRemoveUntil(
  context, MaterialPageRoute(builder: (_) => const LoginScreen()), (_) => false,
);
main.dart:997–1011
endSession()Cat D · Session timing

Signal coverage across this flow

CatCategoryActivated where
ATouchSteps 1 (detector wired), 3 (dashboard interaction)
BKeystrokeStep 4 (transfer form fields)
CNavigationSteps 1 (observer wired), 3 (screen transitions)
DSession timingSteps 2 (session start), 7 (session end)
EMotionPassive from step 1 — sampled continuously once the session starts
FCognitiveStep 5 (send-button hesitation)
GEnvironmentPassive from step 1 — sampled at session start
HSecurityStep 2 (consent + auth events)
IDevice statePassive from step 1 — sampled continuously once the session starts

A newcomer's walk-through

Pulled from the :demo module (com.funkash.bip.demo) in the android-sdk repo. Follow it top to bottom: it's the order a first-time integrator would actually wire things up in, and by the end it touches all nine signal categories.

1

App starts — wire the SDK once

no screen · wiring
BipDemoApp.kt
AegisBip.init(this, AegisConfig(
  clientId = DemoConfig.CLIENT_ID,
  backendUrl = DemoConfig.BACKEND_URL,
  checkpointUrl = DemoConfig.CHECKPOINT_URL,
  authToken = DemoConfig.AUTH_TOKEN,
))
AegisBip.attachToApplication(this) // Category A + C, one call
demo/.../BipDemoApp.kt
AegisBip.init()attachToApplication()
2

Consent, then login

no screen · code only
LoginActivity.kt
// consent dialog "Accept" button
AegisBip.grantConsent()

// on login tap
AegisBip.report(AegisEvent.AuthPromptShownEvent(AuthMethod.PASSWORD))
val success = username.isNotBlank() && password.isNotBlank()
AegisBip.report(AegisEvent.AuthCompletedEvent(AuthMethod.PASSWORD, success, latencyMs))

AegisBip.startSession(subjectId = pseudonymousSubjectId(username, DemoConfig.SUBJECT_SALT))
AegisBip.setTask(BipTask.LOGIN)
demo/.../LoginActivity.kt
grantConsent()startSession()Cat H · Security
3

Home — moving around the app

no screen · code only
HomeActivity.kt
AegisBip.setTask(BipTask.BROWSE)
// touch (A) + navigation (C) already active — no other code on this screen
demo/.../HomeActivity.kt
setTask()Cat A · TouchCat C · Navigation
4

Filling the transfer form

no screen · code only
TransferActivity.kt
AegisBip.setTask(BipTask.TRANSFER)
AegisBip.watchKeystrokes(binding.recipientField)
AegisBip.watchKeystrokes(binding.amountField)
demo/.../TransferActivity.kt
watchKeystrokes()Cat B · Keystroke
5

Confirm — the checkpoint contract

no screen · code only
TransferActivity.kt
AegisBip.timedClick(binding.sendButton) { onSendTapped() }

AegisBip.setTransactionContext(
  counterpartyAccountHash = pseudonymousSubjectId(recipient, DemoConfig.SUBJECT_SALT),
  channel = "bank_transfer", amountNative = amount, currencyCode = "NGN",
)
val verdict = AegisBip.checkpoint(CheckpointReason.TRANSFER_INITIATED)
demo/.../TransferActivity.kt
timedClick()setTransactionContext()checkpoint()Cat F · Cognitive
6

The verdict — allow, or stop the transfer here

no screen · code only
TransferActivity.kt
when {
  verdict == null -> proceed() // FAIL OPEN — our outage, never the user's
  verdict.shouldBlock -> showRiskAlert("Transfer Blocked", ...)
  verdict.requiresStepUp -> showRiskAlert("Transfer Halted — Verification Required", ...)
  else -> proceed() // clean allow
}
demo/.../TransferActivity.kt
shouldBlockrequiresStepUpfail-open on null
7

Logout — end the session

no screen · code only
HomeActivity.kt
AegisBip.endSession() // transmits the session payload
demo/.../HomeActivity.kt
endSession()Cat D · Session timing

A newcomer's walk-through

Pulled from the example/ app in the react-native-sdk repo, verified end-to-end on both Android and iOS. Follow it top to bottom: it's the order a first-time integrator would actually wire things up in, and by the end it touches all nine signal categories.

1

App starts — wire the SDK once

no screen · wiring
App.tsx
await AegisBip.init({
  clientId: DemoConfig.CLIENT_ID,
  clientVersion: DemoConfig.CLIENT_VERSION,
  backendUrl: DemoConfig.BACKEND_URL,
  checkpointUrl: DemoConfig.CHECKPOINT_URL,
  authToken: DemoConfig.AUTH_TOKEN,
  debugMode: __DEV__,
});

// Category A + C, one wrap
return <AegisBipCaptureRoot><SafeAreaView>...</SafeAreaView></AegisBipCaptureRoot>;
example/src/App.tsx
AegisBip.init()AegisBipCaptureRoot
2

Consent, then login

no screen · code only
LoginScreen.tsx
// consent dialog "Accept" button
AegisBip.grantConsent();

// on login tap
AegisBip.report({ type: 'AuthPromptShown', method: AuthMethod.PASSWORD });
const success = username.trim().length > 0 && password.trim().length > 0;
AegisBip.report({ type: 'AuthCompleted', method: AuthMethod.PASSWORD, success, latencyMs });

await AegisBip.startSession(pseudonymousSubjectId(username, DemoConfig.SUBJECT_SALT));
AegisBip.setTask(BipTask.LOGIN);
example/src/screens/LoginScreen.tsx
grantConsent()startSession()Cat H · Security
3

Home — moving around the app

no screen · code only
HomeScreen.tsx
AegisBip.recordScreenVisit('home');
AegisBip.setTask(BipTask.BROWSE);
// touch (A) + navigation (C) already active — no other code on this screen
example/src/screens/HomeScreen.tsx
recordScreenVisit()Cat A · TouchCat C · Navigation
4

Filling the transfer form

no screen · code only
TransferScreen.tsx
const onRecipientChange = useAegisKeystroke();
const onAmountChange = useAegisKeystroke();

<TextInput onChangeText={(text) => { onRecipientChange(text); setRecipient(text); }} />
<TextInput onChangeText={(text) => { onAmountChange(text); setAmount(text); }} />
example/src/screens/TransferScreen.tsx
useAegisKeystroke()Cat B · Keystroke
5

Confirm — the checkpoint contract

no screen · code only
TransferScreen.tsx
const onSendPressed = useAegisTimedAction(async () => {
  AegisBip.setTransactionContext({
    counterpartyAccountHash: pseudonymousSubjectId(recipient, DemoConfig.SUBJECT_SALT),
    channel: 'bank_transfer', amountNative: parsedAmount, currencyCode: 'NGN',
  });
  const verdict = await AegisBip.checkpoint(CheckpointReason.TRANSFER_INITIATED);
});
example/src/screens/TransferScreen.tsx
useAegisTimedAction()setTransactionContext()checkpoint()Cat F · Cognitive
6

The verdict — allow, or stop the transfer here

no screen · code only
TransferScreen.tsx
if (verdict == null) {
  setStatus('Sent (offline verdict — failed open).'); // FAIL OPEN — our outage, never the user's
} else if (shouldBlock(verdict)) {
  showRiskAlert('Transfer Blocked', ...);
} else if (requiresStepUp(verdict)) {
  showRiskAlert('Transfer Halted — Verification Required', ...);
} else {
  setStatus(`Transfer confirmed — risk ${...}%.`); // clean allow
}
example/src/screens/TransferScreen.tsx
shouldBlockrequiresStepUpfail-open on null
7

Logout — end the session

no screen · code only
HomeScreen.tsx
await AegisBip.endSession(); // transmits the session payload
example/src/screens/HomeScreen.tsx
endSession()Cat D · Session timing

Signal coverage across this flow

CatCategoryActivated where
ATouchSteps 1 (root wired), 3 (dashboard interaction)
BKeystrokeStep 4 (transfer form fields)
CNavigationSteps 2, 3, 4 (recordScreenVisit per screen)
DSession timingSteps 2 (session start), 7 (session end)
EMotionPassive from step 1 — native TurboModule streaming once the session starts
FCognitiveStep 5 (send-button hesitation via useAegisTimedAction)
GEnvironmentPassive from step 1 — sampled at session start
HSecurityStep 2 (consent + auth events)
IDevice statePassive from step 1 — sampled continuously once the session starts

Changelog

1.1.0

  • Added pseudonymousSubjectId() helper — promoted out of the example app.
  • Added AegisTimedAction widget — promoted out of the example app.
  • sessionManager is now optional on AegisTextField / AegisTouchDetector, falling back to AegisBIP.instance.sessionManager.

1.0.3

  • AegisConfig now defaults useProtobuf to false (JSON) — the previous default silently dropped roughly 36 of 63 scored features.
  • Revoking consent now discards the session instead of transmitting it.

Changelog

0.1.0 — initial build

A ground-up native SDK, not a repackaging of the Flutter SDK. Signal categories and the wire contract match the Flutter SDK's field-for-field.

  • Full A–J signal payload schema, JSON-only.
  • Categories G/H/I native readers — telephony, permissions, sensor inventory, screen-capture/remote-access/accessibility-fairness detection.
  • Categories A/C capture via a Window.Callback decorator and Application.ActivityLifecycleCallbacks.
  • Categories B/D/E/F capture — keystroke TextWatcher, session timing, real SensorManager motion, cognitive hesitation timing.
  • Full auth/credential event handling for Category H.
  • AegisBip public singleton and a :demo reference app exercising the full login → home → transfer → checkpoint → logout lifecycle.

Known gaps: G_environment's network-type/ VPN/battery/location fields aren't wired yet.

Changelog

0.1.0 — initial build

Mirrors the Flutter SDK's architecture: orchestration and Categories A/B/C/D/F run in the cross-platform runtime, and native TurboModules cover only what neither TypeScript nor a community package can reach. Signal categories and the wire contract match the Flutter and native Android SDKs' field-for-field.

  • Full A–J signal payload types (SessionPayload.ts), JSON-only.
  • TransportLayer — fetch/AbortController-based checkpoint/training transport with a real 5s hard timeout, fail-open on any failure, offline retry queue, remote kill-switch parsing.
  • Native TurboModule (Kotlin + Objective-C++) for Categories G (telephony/brightness)/H (screen-capture/remote-access)/I (permissions/sensor inventory), ported from AegisBipPlugin.kt/.swift.
  • Native motion-sensor streaming (startMotionUpdates/getLatestMotionSample) built directly into the same TurboModule after react-native-sensors — the obvious community-package choice — turned out to be unmaintained (years-out-of-date Android Gradle/AGP toolchain, no arm64-v8a in its ABI filter).
  • Pure-TypeScript capture for Category A (touch, via RN's passive bubble-phase touch events — never the responder system), B (keystroke, via TextInput's length-delta comparisons), C (navigation, manual recordScreenVisit API), D (session timing), F (cognitive/hesitation).
  • AegisBip public singleton and an example/ reference app exercising the full login → home → transfer → checkpoint → logout lifecycle, emulator/simulator-verified end-to-end on both Android and iOS.

Known gaps: G_environment's network-type/ VPN/battery/location fields aren't wired yet; iOS accessibility/remote-control detection and is_roaming carry the same known gaps as the Flutter SDK's iOS plugin; touch contact area is always null on both platforms; not yet verified against a real backend end-to-end.