Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ dependencies {

// transact deps end
implementation "financial.atomic:transact:$transactVersion"
// Decode the JS data-request response into the SDK's @Serializable TransactDataResponse.
// (kotlinx-serialization-json is already a transitive runtime dep of the SDK.)
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3"
}

if (isNewArchitectureEnabled()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ import com.facebook.react.modules.core.DeviceEventManagerModule
import financial.atomic.transact.Config
import financial.atomic.transact.Transact
import financial.atomic.transact.receiver.TransactBroadcastReceiver
import org.json.JSONObject
import java.lang.Exception
import java.util.concurrent.ConcurrentHashMap
import kotlinx.serialization.json.Json
import org.json.JSONObject

class TransactReactNativeModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {

// jsInstanceId -> receiver. Used only for instance-targeted commands (resolveDataRequest);
// event routing is handled by each receiver's own envelope. Entries are dropped on cleanup.
private val receivers = ConcurrentHashMap<String, TransactBroadcastReceiver>()
private val json = Json { ignoreUnknownKeys = true }

override fun getName(): String {
return NAME
}
Expand Down Expand Up @@ -47,20 +54,19 @@ class TransactReactNativeModule(reactContext: ReactApplicationContext) :
}
}

private fun handleCallbackEvent(
eventName: String,
data: JSONObject,
fieldName: String,
// Every event is wrapped in a { instanceId, data } envelope so the JS layer can route it to the
// task that owns `instanceId` (SDK-658). `data` is null for argument-less events (onLaunch).
private fun emitEnvelope(
emitter: DeviceEventManagerModule.RCTDeviceEventEmitter,
promise: Promise
eventName: String,
instanceId: String,
data: JSONObject?,
) {
val value = data.optString(fieldName)
val result = Arguments.createMap().apply {
putString(fieldName, value)
val envelope = JSONObject().apply {
put("instanceId", instanceId)
put("data", data ?: JSONObject.NULL)
}

emitter.emit(eventName, data.toString())
promise.resolve(result)
emitter.emit(eventName, envelope.toString())
}

private fun buildConfigToken(config: ReadableMap, wrapperVersion: String): String {
Expand All @@ -75,50 +81,59 @@ class TransactReactNativeModule(reactContext: ReactApplicationContext) :

@ReactMethod
fun presentTransact(
instanceId: String,
config: ReadableMap,
environment: ReadableMap,
wrapperVersion: String,
setDebug: Boolean,
promise: Promise,
) {
val context = reactApplicationContext.currentActivity as Context
val context = reactApplicationContext.currentActivity as? Context
if (context == null) {
promise.reject("no_activity", "No current Activity to present Transact from")
return
}
val emitter = reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
val environmentURL = parseEnvironment(environment)
val token = buildConfigToken(config, wrapperVersion)
val sdkConfig = Config(token = token, environment = "CUSTOM", environmentURL = environmentURL, debug = setDebug)

UiThreadUtil.runOnUiThread {
try {
Transact.present(context, sdkConfig, object : TransactBroadcastReceiver() {
// Each launch gets its own receiver capturing this call's JS instanceId. The native SDK
// binds its own per-instance id to the receiver and routes broadcasts so this receiver
// only ever sees its own session's events (SDK-659). We re-tag each emit with the JS
// instanceId so the JS registry can route to this task's handlers.
val receiver = object : TransactBroadcastReceiver() {
override fun onClose(data: JSONObject) {
handleCallbackEvent("onClose", data, "reason", emitter, promise)
emitEnvelope(emitter, "onClose", instanceId, data)
}

override fun onFinish(data: JSONObject) {
handleCallbackEvent("onFinish", data, "taskId", emitter, promise)
emitEnvelope(emitter, "onFinish", instanceId, data)
}

override fun onLaunch() {
emitter.emit("onLaunch", null)
emitEnvelope(emitter, "onLaunch", instanceId, null)
}

override fun onInteraction(data: JSONObject) {
emitter.emit("onInteraction", data.toString())
emitEnvelope(emitter, "onInteraction", instanceId, data)
}

override fun onDataRequest(data: JSONObject) {
emitter.emit("onDataRequest", data.toString())
emitEnvelope(emitter, "onDataRequest", instanceId, data)
}

override fun onAuthStatusUpdate(data: JSONObject) {
emitter.emit("onAuthStatusUpdate", data.toString())
emitEnvelope(emitter, "onAuthStatusUpdate", instanceId, data)
}

override fun onTaskStatusUpdate(data: JSONObject) {
if (!data.has("failReason")) {
data.put("failReason", JSONObject.NULL)
}
emitter.emit("onTaskStatusUpdate", data.toString())
emitEnvelope(emitter, "onTaskStatusUpdate", instanceId, data)
}

override fun onDebugLog(
Expand All @@ -127,15 +142,48 @@ class TransactReactNativeModule(reactContext: ReactApplicationContext) :
message: String,
data: JSONObject
) {
// Debug log is process-global (no per-task instanceId), like iOS.
emitter.emit("onDebugLog", data.toString())
}
})

override fun onCleanup() {
// Terminal (carries no data): the JS registry tears down this task here. Drop our
// command-targeting ref.
emitEnvelope(emitter, "onCleanup", instanceId, null)
receivers.remove(instanceId)
}
}

receivers[instanceId] = receiver
Transact.present(context, sdkConfig, receiver)
// Resolve as a launch ack. Lifecycle callbacks are delivered via the enveloped events above.
promise.resolve(null)
} catch (e: Exception) {
receivers.remove(instanceId)
promise.reject(e)
}
}
}

// Sends a data-request response back to the originating task. The JS layer calls this with the
// wrapper instanceId; we resolve the SDK's per-instance id from the bound receiver and target it
// so concurrent flows don't each receive the response.
@ReactMethod
fun resolveDataRequest(instanceId: String, response: ReadableMap?) {
if (response == null) {
return
}
val sdkInstanceId = receivers[instanceId]?.instanceId ?: return
try {
val responseJson = JSONObject(response.toHashMap()).toString()
val dataResponse =
json.decodeFromString(Config.TransactDataResponse.serializer(), responseJson)
Transact.sendData(reactApplicationContext, sdkInstanceId, dataResponse)
} catch (e: Exception) {
// Malformed/empty response — nothing to deliver.
}
}

companion object {
const val NAME = "TransactReactNative"
}
Expand Down
17 changes: 9 additions & 8 deletions ios/TransactReactNative.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@

@interface RCT_EXTERN_MODULE(TransactReactNative, RCTEventEmitter)

RCT_EXTERN_METHOD(presentTransact:(NSDictionary *)config
environment:(NSDictionary *)environment
presentationStyle:(nullable NSString *)presentationStyle
setDebug:(nullable NSNumber *)setDebug
wrapperVersion:(NSString *)wrapperVersion
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(presentTransact:(NSString *)instanceId
config:(NSDictionary *)config
environment:(NSDictionary *)environment
presentationStyle:(nullable NSString *)presentationStyle
setDebug:(nullable NSNumber *)setDebug
wrapperVersion:(NSString *)wrapperVersion
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(resolveDataRequest:(id)data)
RCT_EXTERN_METHOD(resolveDataRequest:(NSString *)instanceId data:(id)data)

RCT_EXTERN_METHOD(hideTransact:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
Expand Down
66 changes: 37 additions & 29 deletions ios/TransactReactNative.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import UIKit
@objc(TransactReactNative)
class TransactReactNative: RCTEventEmitter {

// Data request handler that will be called when the response arrives
private var dataResponseHandler: ((Any) -> Void)? = nil
// Data-request continuations keyed by instanceId, so concurrent/overlapping requests across
// tasks don't collide (the previous single handler was overwritten — SDK-658).
private var dataResponseHandlers: [String: (Any) -> Void] = [:]

private func parseEnvironment(_ environmentData: [String: Any]) -> AtomicTransact.TransactEnvironment {
guard let environment = environmentData["environment"] as? String else {
Expand Down Expand Up @@ -37,8 +38,8 @@ class TransactReactNative: RCTEventEmitter {
}
}

@objc(presentTransact:environment:presentationStyle:setDebug:wrapperVersion:withResolver:withRejecter:)
func presentTransact(config: [String: Any], environment: [String: Any], presentationStyle: String?, setDebug: NSNumber?, wrapperVersion: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
@objc(presentTransact:config:environment:presentationStyle:setDebug:wrapperVersion:withResolver:withRejecter:)
func presentTransact(instanceId: String, config: [String: Any], environment: [String: Any], presentationStyle: String?, setDebug: NSNumber?, wrapperVersion: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
let debugEnabled = setDebug?.boolValue ?? false

Task { @MainActor in
Expand All @@ -65,13 +66,13 @@ class TransactReactNative: RCTEventEmitter {
Atomic.presentTransact(
from: source, config: config, environment: parsedEnvironment, presentationStyle: parsedPresentationStyle,
onInteraction: { interaction in
self.sendEvent(withName: "onInteraction", body: ["name": interaction.name, "value": interaction.value])
self.sendEvent(withName: "onInteraction", body: ["instanceId": instanceId, "data": ["name": interaction.name, "value": interaction.value]])
},
onDataRequest: { request async -> TransactDataResponse? in
// Create a task to handle the async request to React Native
return await withCheckedContinuation { continuation in
// Store the completion handler
self.dataResponseHandler = { responseData in
self.dataResponseHandlers[instanceId] = { responseData in
if let responseDict = responseData as? [String: Any] {
// The SDK expects the response data to be passed directly
// Let the SDK handle the parsing internally
Expand All @@ -92,29 +93,44 @@ class TransactReactNative: RCTEventEmitter {
}

// Send event with request data to React Native
self.sendEvent(withName: "onDataRequest", body: request.data)
self.sendEvent(withName: "onDataRequest", body: ["instanceId": instanceId, "data": request.data])
}
},
onAuthStatusUpdate: { status in
self.sendEvent(withName: "onAuthStatusUpdate", body: status.serialize())
self.sendEvent(withName: "onAuthStatusUpdate", body: ["instanceId": instanceId, "data": status.serialize()])
},
onTaskStatusUpdate: { status in
self.sendEvent(withName: "onTaskStatusUpdate", body: status.serialize())
self.sendEvent(withName: "onTaskStatusUpdate", body: ["instanceId": instanceId, "data": status.serialize()])
},
onLaunch: {
self.sendEvent(withName: "onLaunch", body: [])
self.sendEvent(withName: "onLaunch", body: ["instanceId": instanceId, "data": NSNull()])
},
onCompletion: { result in
switch result {
case .finished(let response):
self.sendEvent(withName: "onFinish", body: ["instanceId": instanceId, "data": response.data])
resolve(["finished": response.data])
case .closed(let response):
self.sendEvent(withName: "onClose", body: ["instanceId": instanceId, "data": response.data])
resolve(["closed": response.data])
case .error:
resolve(["error": "Unknown error"])
default:
resolve(["error": "Unknown error"])
}
},
onError: { error in
// In-flow SDK error channel (distinct from onCompletion's terminal load errors).
// Routed per-task like every other event; not terminal on its own.
if case let .transactError(data) = error {
self.sendEvent(withName: "onError", body: ["instanceId": instanceId, "data": data])
} else {
self.sendEvent(withName: "onError", body: ["instanceId": instanceId, "data": NSNull()])
}
},
onCleanup: {
self.sendEvent(withName: "onCleanup", body: ["instanceId": instanceId, "data": NSNull()])
self.dataResponseHandlers[instanceId] = nil
}
)
}
Expand All @@ -124,35 +140,27 @@ class TransactReactNative: RCTEventEmitter {
}
}

// Method to receive response from React Native
@objc(resolveDataRequest:)
func resolveDataRequest(data: Any) -> Void {
// Call the stored response handler with the data from React Native
if let handler = dataResponseHandler {
// Receives a data-request response from React Native, routed to the originating task by id.
@objc(resolveDataRequest:data:)
func resolveDataRequest(instanceId: String, data: Any) -> Void {
if let handler = dataResponseHandlers[instanceId] {
handler(data)

// Clear the handler
dataResponseHandler = nil
dataResponseHandlers[instanceId] = nil
}
}

@objc(hideTransact:withRejecter:)
func hideTransact(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
DispatchQueue.main.async {
do {
Atomic.hideTransact()
} catch {
reject("dismiss_error", "Error dismissing Transact", error)
}
// Atomic.hideTransact() is non-throwing and process-global (hides every presented
// session). Resolve so the JS promise settles instead of hanging forever.
Atomic.hideTransact()
resolve(nil)
}
}

@objc override func supportedEvents() -> [String] {
return ["onInteraction", "onDataRequest", "onLaunch", "onCompletion", "onAuthStatusUpdate", "onTaskStatusUpdate", "onDebugLog"]
}

@objc override static func requiresMainQueueSetup() -> Bool {
return true
return ["onInteraction", "onDataRequest", "onLaunch", "onFinish", "onClose", "onCleanup", "onError", "onAuthStatusUpdate", "onTaskStatusUpdate", "onDebugLog"]
}
}

Expand Down
Loading
Loading