Skip to content

[Android] "Array already consumed" on reload. getThreatIdentifiers resolves a cached WritableNativeArray #152

@vilsongabriel

Description

@vilsongabriel

Describe the bug

On Android, getThreatIdentifiers() and getRaspExecutionStateIdentifiers() resolve a statically cached WritableNativeArray (ThreatEvent.ALL_EVENTS / RaspExecutionStateEvent.ALL_EVENTS, defined as companion object val). A WritableNativeArray can only cross the React Native bridge once, after it is serialized it is flagged as consumed. Because the array lives for the whole process lifetime while the React context is re-created on every JS reload, the second resolve of that same instance throws:

Exception in HostFunction: Array already consumed
com.facebook.react.bridge.ObjectAlreadyConsumedException: Array already consumed

It surfaces as an unhandled promise rejection coming from FreeraspReactNativeModule.getThreatIdentifiers.

Root cause (same code in 4.5.2 and 5.0.0):

// events/ThreatEvent.kt - process-lifetime cached WritableNativeArray
companion object {
  internal val ALL_EVENTS = Arguments.fromList(
    listOf(AppIntegrity, PrivilegedAccess, Debug, /* ... */ Automation).map { it.value }
  )
}

// FreeraspReactNativeModule.kt - resolves the SAME instance every call
@ReactMethod
fun getThreatIdentifiers(promise: Promise) {
  promise.resolve(ThreatEvent.ALL_EVENTS)
}

@ReactMethod
fun getRaspExecutionStateIdentifiers(promise: Promise) {
  promise.resolve(RaspExecutionStateEvent.ALL_EVENTS)
}

Arguments.fromList(...) returns a WritableNativeArray (single-use across the bridge). The JS side (useFreeRaspgetThreatIdentifiers() in channels/threat.ts) runs on every React-context init:

  • 1st init: resolves ALL_EVENTS → consumed (OK).
  • 2nd init in the same process (reload/restart): resolves the already-consumed ALL_EVENTS → throws.

@ReactMethod + ReactContextBaseJavaModule route through legacy-bridge interop even under the New Architecture, so Arguments.fromList yields a WritableNativeArray regardless.

To Reproduce

  1. Integrate freerasp-react-native and start it via useFreeRasp(config, actions).
  2. Build & run a debug Android app; let freeRASP initialize.
  3. Trigger a JS bundle reload (Dev Menu → Reload, or press r in Metro). Anything that re-creates the React context within the same process.
  4. The unhandled promise rejection/red box appears.

The real trigger is "the RN bridge/context is initialized a second time in the same OS process," so it can also occur in production via react-native-restart, reload-after-update flows, or certain activity/bridge recreation paths, not strictly dev-only.

Expected behavior

getThreatIdentifiers() / getRaspExecutionStateIdentifiers() resolve successfully on every call, including after a JS reload or any second React-context initialization in the same process.

Screenshots

Full stack trace (logcat):

W ReactNativeJS: 'Unhandled promise rejection!', 0, { [Error: Exception in HostFunction: Array already consumed]
  cause:
   { name: 'com.facebook.react.bridge.ObjectAlreadyConsumedException',
     message: 'Array already consumed',
     stackElements:
      [ { className: 'com.facebook.react.bridge.WritableNativeArray', fileName: 'WritableNativeArray.kt', lineNumber: -2, methodName: 'pushNativeArray' },
        { className: 'com.facebook.react.bridge.WritableNativeArray', fileName: 'WritableNativeArray.kt', lineNumber: 38, methodName: 'pushArray' },
        { className: 'com.facebook.react.bridge.Arguments', fileName: 'Arguments.kt', lineNumber: 158, methodName: 'fromJavaArgs' },
        { className: 'com.facebook.react.bridge.CxxCallbackImpl', fileName: 'CxxCallbackImpl.kt', lineNumber: 18, methodName: 'invoke' },
        { className: 'com.facebook.react.bridge.PromiseImpl', fileName: 'PromiseImpl.kt', lineNumber: 29, methodName: 'resolve' },
        { className: 'com.freeraspreactnative.FreeraspReactNativeModule', fileName: 'FreeraspReactNativeModule.kt', lineNumber: 119, methodName: 'getThreatIdentifiers' },
        { className: 'com.facebook.jni.NativeRunnable', methodName: 'run' } ] } }

Please complete the following information:

  • Device: Android (any); reproduced on a Pixel 9 emulator
  • OS version: Android 14
  • Version of freeRASP: 4.5.2 (confirmed); same code present in 5.0.0 (latest)

Additional context

  • React Native 0.83.6, New Architecture enabled (newArchEnabled=true), Expo SDK 55. Android only — iOS has no WritableNativeArray consume semantics.

Suggested fix: cache the plain values, build a fresh WritableArray per call so each resolve gets its own consumable instance:

// events/ThreatEvent.kt
companion object {
  internal val ALL_EVENT_IDENTIFIERS: List<Int> =
    listOf(AppIntegrity, PrivilegedAccess, Debug, /* ... */ Automation).map { it.value }
}

// FreeraspReactNativeModule.kt
@ReactMethod
fun getThreatIdentifiers(promise: Promise) {
  promise.resolve(Arguments.fromList(ThreatEvent.ALL_EVENT_IDENTIFIERS))
}

@ReactMethod
fun getRaspExecutionStateIdentifiers(promise: Promise) {
  promise.resolve(Arguments.fromList(RaspExecutionStateEvent.ALL_EVENT_IDENTIFIERS))
}

Equivalent: Arguments.fromList(ALL_EVENTS.toArrayList()) per call. Same pattern applies to any other @ReactMethod that resolves a cached WritableArray/WritableMap.

Consumer workaround until released: patch-package on FreeraspReactNativeModule.kt, swapping the two promise.resolve(...ALL_EVENTS) lines for the fresh-array variant.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions