@juspay-tech/react-native-hyperswitch 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/android/build.gradle +22 -1
  2. package/android/src/main/assets/hyperswitch.bundle +590 -590
  3. package/android/src/main/java/com/hyperswitchsdkreactnative/modules/NativeLog.kt +107 -0
  4. package/android/src/main/java/com/hyperswitchsdkreactnative/modules/ReactNativeHyperswitchModule.kt +318 -117
  5. package/android/src/main/java/io/hyperswitch/paymentsession/ExitHeadlessCallBackManager.kt +4 -2
  6. package/android/src/main/java/io/hyperswitch/paymentsession/PaymentSessionHandlerImpl.kt +20 -4
  7. package/android/src/main/java/io/hyperswitch/paymentsession/PaymentSessionReactLauncher.kt +104 -9
  8. package/android/src/main/java/io/hyperswitch/paymentsheet/PaymentResult.kt +7 -1
  9. package/android/src/main/java/io/hyperswitch/react/HyperModule.kt +13 -0
  10. package/android/src/main/java/io/hyperswitch/view/PaymentWidgetView.kt +27 -4
  11. package/android/src/rn79/assets/hyperswitch.bundle +590 -590
  12. package/android/src/rn79/java/io/hyperswitch/react/HyperFragment.kt +20 -0
  13. package/android/src/rn81/java/io/hyperswitch/react/HyperFragment.kt +20 -0
  14. package/ios/Modules/ReactNative/NativeHyperswitchModuleImpl.swift +3 -1
  15. package/ios/hyperswitchSDK/Core/ApplePay/ApplePayHandler.swift +49 -38
  16. package/ios/hyperswitchSDK/Core/HyperPaymentSheet/PaymentSheetView+UIKit.swift +1 -5
  17. package/ios/hyperswitchSDK/Core/NativeModule/HyperModuleImpl.swift +49 -34
  18. package/ios/hyperswitchSDK/Core/NativeModule/RNHeadlessManager.swift +2 -12
  19. package/ios/hyperswitchSDK/Core/Resources/hyperswitch-rn82plus.bundle +590 -590
  20. package/ios/hyperswitchSDK/Core/Resources/hyperswitch.bundle +590 -590
  21. package/ios/hyperswitchSDK/Shared/Environment.swift +49 -3
  22. package/ios/hyperswitchSDK/Shared/HyperswitchEnvironment.swift +1 -0
  23. package/ios/hyperswitchSDK/Shared/NativeLogger/LogBuilder.swift +15 -0
  24. package/ios/hyperswitchSDK/Shared/NativeLogger/LogManager.swift +10 -2
  25. package/ios/hyperswitchSDK/Shared/NativeLogger/LogPayload.swift +1 -0
  26. package/ios/hyperswitchSDK/Shared/NativeLogger/NativeLog.swift +101 -0
  27. package/ios/hyperswitchSDK/Shared/PaymentSession.swift +8 -1
  28. package/lib/commonjs/index.bundle.js +2 -2
  29. package/lib/commonjs/index.bundle.js.map +1 -1
  30. package/lib/module/index.bundle.js +2 -2
  31. package/lib/module/index.bundle.js.map +1 -1
  32. package/lib/typescript/src/context/HyperElements.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts.map +1 -1
  34. package/lib/typescript/src/native/NativeResponseMapper.d.ts +2 -1
  35. package/lib/typescript/src/native/NativeResponseMapper.d.ts.map +1 -1
  36. package/lib/typescript/src/session/SavedPaymentMethods.d.ts.map +1 -1
  37. package/lib/typescript/src/session/presentPaymentSheet.d.ts +0 -12
  38. package/lib/typescript/src/session/presentPaymentSheet.d.ts.map +1 -1
  39. package/lib/typescript/src/views/PaymentElement.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/context/HyperElements.tsx +7 -6
  42. package/src/index.ts +9 -1
  43. package/src/native/NativeResponseMapper.ts +20 -4
  44. package/src/session/SavedPaymentMethods.ts +10 -2
  45. package/src/session/presentPaymentSheet.ts +7 -1
  46. package/src/views/PaymentElement.tsx +17 -8
@@ -20,10 +20,16 @@ import com.facebook.react.jstasks.HeadlessJsTaskContext
20
20
  import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler
21
21
  import com.facebook.react.uimanager.PixelUtil
22
22
  import com.hyperswitchsdkreactnative.BuildConfig
23
+ import com.proyecto26.inappbrowser.ChromeTabsDismissedEvent
24
+ import com.proyecto26.inappbrowser.ChromeTabsManagerActivity
23
25
  import io.hyperswitch.react.HyperActivity
24
26
  import io.hyperswitch.react.HyperEventEmitter
25
27
  import io.hyperswitch.react.HyperFragment
26
28
  import io.hyperswitch.react.ReactNativeController
29
+ import io.hyperswitch.redirect.RedirectEvent
30
+ import java.lang.ref.WeakReference
31
+ import org.greenrobot.eventbus.EventBus
32
+ import org.greenrobot.eventbus.Subscribe
27
33
 
28
34
  /**
29
35
  * React Native backed engine for presenting the payment sheet and running headless tasks.
@@ -32,9 +38,19 @@ import io.hyperswitch.react.ReactNativeController
32
38
  * to pass all configuration data as plain [Bundle]s or [Map]s.
33
39
  */
34
40
  class PaymentSessionReactLauncher(
35
- private val activity: Activity,
41
+ private val activityProvider: () -> Activity?,
36
42
  ) {
37
43
 
44
+ constructor(activity: Activity) : this({ activity })
45
+
46
+ /**
47
+ * The host Activity, read on every use rather than captured once: a host Activity that is
48
+ * recreated (configuration change, recreate()) would otherwise leave the sheet committed to the
49
+ * destroyed Activity, where it is silently dropped.
50
+ */
51
+ private val activity: Activity
52
+ get() = checkNotNull(activityProvider()) { "No current Activity" }
53
+
38
54
  private var reactHost: ReactHost? = null
39
55
  private var reactNativeHost: ReactNativeHost? = null
40
56
  private var reactContext: ReactContext? = null
@@ -48,6 +64,7 @@ class PaymentSessionReactLauncher(
48
64
  if (!ReactNativeController.getIsInitialized()) {
49
65
  ReactNativeController.initialize(activity.application)
50
66
  }
67
+ RedirectBridge.install(activity)
51
68
  reactNativeHost = ReactNativeController.getReactNativeHost()
52
69
  reactHost = ReactNativeController.getReactHost()
53
70
 
@@ -75,7 +92,8 @@ class PaymentSessionReactLauncher(
75
92
  * Recreates the React context (if needed) and starts a headless JS task with the supplied
76
93
  * [bundle]. The caller is responsible for assembling the bundle (e.g. via [LaunchOptions]).
77
94
  */
78
- fun recreateReactContext(bundle: Bundle) {
95
+ fun recreateReactContext(bundle: Bundle): Boolean {
96
+ val activity = activityProvider() ?: return false
79
97
  activity.runOnUiThread {
80
98
  var context = reactContext
81
99
  if (context == null) {
@@ -136,6 +154,7 @@ class PaymentSessionReactLauncher(
136
154
  startHeadlessTask(context, bundle)
137
155
  }
138
156
  }
157
+ return true
139
158
  }
140
159
 
141
160
  private fun startHeadlessTask(reactContext: ReactContext, bundle: Bundle) {
@@ -145,6 +164,7 @@ class PaymentSessionReactLauncher(
145
164
 
146
165
  val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactContext)
147
166
  UiThreadUtil.runOnUiThread {
167
+ resumeHost()
148
168
  headlessTaskId?.let {
149
169
  headlessJsTaskContext.finishTask(it)
150
170
  }
@@ -152,19 +172,63 @@ class PaymentSessionReactLauncher(
152
172
  }
153
173
  }
154
174
 
175
+ /**
176
+ * Headless flows (wallet buttons, saved methods) have no ReactFragment/ReactActivity to resume
177
+ * the SDK host, and React Native runs JS timers (so every `fetch`) and sets `currentActivity`
178
+ * only for a resumed host. The headless task itself ends immediately, so resume it here.
179
+ */
180
+ private fun resumeHost() {
181
+ val activity = activityProvider()?.takeUnless { it.isFinishing || it.isDestroyed } ?: return
182
+ RedirectBridge.install(activity)
183
+ val backHandler = activity as? DefaultHardwareBackBtnHandler
184
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
185
+ ReactNativeController.getReactHost().onHostResume(activity, backHandler)
186
+ } else {
187
+ val manager = ReactNativeController.getReactNativeHost().reactInstanceManager
188
+ if (backHandler != null) manager.onHostResume(activity, backHandler) else manager.onHostResume(activity)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resumes the SDK host with the current Activity. Headless flows started from a session created
194
+ * before the host Activity was recreated would otherwise launch from the destroyed one.
195
+ */
196
+ fun resumeHostForCurrentActivity() {
197
+ UiThreadUtil.runOnUiThread { resumeHost() }
198
+ }
199
+
200
+ /** Like [resumeHostForCurrentActivity], then runs [action] after the resume, on the UI thread. */
201
+ fun runWithHostResumed(action: () -> Unit) {
202
+ UiThreadUtil.runOnUiThread {
203
+ resumeHost()
204
+ action()
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Whether a payment sheet is currently attached and visible on [activity]. Closed sheets are
210
+ * hidden, not removed, so they do not count.
211
+ */
212
+ fun isSheetVisible(activity: Activity): Boolean {
213
+ val fragmentActivity = activity as? FragmentActivity ?: return false
214
+ return fragmentActivity.supportFragmentManager.fragments.any {
215
+ it.tag == "paymentSheet" && it.isAdded && !it.isHidden
216
+ }
217
+ }
218
+
155
219
  /**
156
220
  * Presents the payment sheet using a fully prepared launch [bundle].
157
221
  *
158
222
  * The bundle is expected to contain a `props` bundle with `configuration`, `sdkParams`, etc.
159
223
  */
160
- fun presentSheet(bundle: Bundle): Boolean {
224
+ fun presentSheet(bundle: Bundle, host: Activity = activity): Boolean {
161
225
  applyFonts(bundle)
162
- return presentSheetInternal(bottomInsetToDIPFromPixel(bundle))
226
+ return presentSheetInternal(bottomInsetToDIPFromPixel(bundle), host)
163
227
  }
164
228
 
165
- private fun presentSheetInternal(bundle: Bundle): Boolean {
166
- if (activity is DefaultHardwareBackBtnHandler && activity is FragmentActivity) {
167
- val fragmentActivity = activity as FragmentActivity
229
+ private fun presentSheetInternal(bundle: Bundle, host: Activity): Boolean {
230
+ if (host is DefaultHardwareBackBtnHandler && host is FragmentActivity) {
231
+ val fragmentActivity = host as FragmentActivity
168
232
  val fragmentManager = fragmentActivity.supportFragmentManager
169
233
  try {
170
234
  fragmentManager.findFragmentByTag("paymentSheet")?.let { existingFragment ->
@@ -192,9 +256,9 @@ class PaymentSessionReactLauncher(
192
256
 
193
257
  return true
194
258
  } else {
195
- activity.startActivity(
259
+ host.startActivity(
196
260
  Intent(
197
- activity.applicationContext,
261
+ host.applicationContext,
198
262
  HyperActivity::class.java
199
263
  ).apply {
200
264
  putExtra("flow", 1)
@@ -253,4 +317,35 @@ class PaymentSessionReactLauncher(
253
317
  }
254
318
  return bundle
255
319
  }
320
+
321
+ /**
322
+ * The in-app browser returns through RedirectActivity, which posts a [RedirectEvent]. Only
323
+ * HyperFragment and HyperActivity turn that into the browser's own dismiss event, and the
324
+ * wallet-button and saved-methods flows have neither on screen, so the tab stayed open and the
325
+ * confirm never resumed. Registered once per process; a duplicate post while a sheet is up is
326
+ * ignored, because the browser drops it when no open call is pending.
327
+ */
328
+ private object RedirectBridge {
329
+ /* Weak: this object outlives the session, and the dismiss intent must be started from the
330
+ Activity, as HyperFragment does, so it lands in the task holding the browser tab. */
331
+ private var activityRef: WeakReference<Activity>? = null
332
+
333
+ fun install(activity: Activity) {
334
+ activityRef = WeakReference(activity)
335
+ val eventBus = EventBus.getDefault()
336
+ if (!eventBus.isRegistered(this)) {
337
+ eventBus.register(this)
338
+ }
339
+ }
340
+
341
+ @Subscribe
342
+ fun onEvent(event: RedirectEvent) {
343
+ Log.d("HyperswitchRedirect", "RedirectBridge got redirect: ${event.message}")
344
+ EventBus.getDefault().post(
345
+ ChromeTabsDismissedEvent(event.message, event.resultType, event.isError)
346
+ )
347
+ val activity = activityRef?.get() ?: return
348
+ activity.startActivity(ChromeTabsManagerActivity.createDismissIntent(activity))
349
+ }
350
+ }
256
351
  }
@@ -42,9 +42,15 @@ sealed class PaymentResult : Parcelable {
42
42
  }
43
43
 
44
44
  is Failed -> {
45
+ val text = throwable.message?.takeIf { it.isNotEmpty() }
46
+ val code = throwable.cause?.message?.takeIf { it.isNotEmpty() }
45
47
  JSONObject()
46
48
  .put("status", "failed")
47
- .put("error", throwable.message)
49
+ // `error` is kept for existing consumers; `message`/`code` match StandardResult
50
+ // and what the JS layer reads.
51
+ .put("error", text)
52
+ .put("message", text)
53
+ .put("code", code)
48
54
  .put("type", throwable::class.java.name)
49
55
  .toString()
50
56
  }
@@ -23,6 +23,7 @@ import com.facebook.react.uimanager.UIManagerModule
23
23
  import com.facebook.react.uimanager.common.UIManagerType
24
24
  import com.hyperswitchsdkreactnative.BuildConfig
25
25
  import com.hyperswitchsdkreactnative.NativeHyperModuleSpec
26
+ import com.hyperswitchsdkreactnative.modules.NativeLog
26
27
  import io.hyperswitch.payments.GooglePayCallbackManager
27
28
  import io.hyperswitch.paymentsession.LaunchOptions
28
29
  import io.hyperswitch.paymentsession.PaymentSheetCallbackManager
@@ -67,6 +68,7 @@ class HyperModule internal constructor(private val rct: ReactApplicationContext)
67
68
  }
68
69
 
69
70
  init {
71
+ NativeLog.attach(rct)
70
72
  activeInstance = this
71
73
  }
72
74
 
@@ -143,11 +145,17 @@ class HyperModule internal constructor(private val rct: ReactApplicationContext)
143
145
  // Method to launch Google Pay payment
144
146
  @ReactMethod
145
147
  override fun launchGPay(googlePayRequest: String, callBack: Callback) {
148
+ val activityName = currentActivity?.javaClass?.simpleName
149
+ NativeLog.debug("wallet_launch_called", mapOf("wallet" to "google_pay", "activity" to activityName))
150
+ if (currentActivity == null) {
151
+ NativeLog.warning("wallet_launch_without_activity", mapOf("wallet" to "google_pay"))
152
+ }
146
153
  currentActivity?.let {
147
154
  GooglePayCallbackManager.setCallback(
148
155
  it,
149
156
  googlePayRequest,
150
157
  fun(data: Map<String, Any?>) {
158
+ NativeLog.debug("wallet_result_delivered", mapOf("wallet" to "google_pay", "status" to data["status"]?.toString()))
151
159
  callBack.invoke(
152
160
  Arguments.fromBundle(
153
161
  LaunchOptions(
@@ -162,6 +170,7 @@ class HyperModule internal constructor(private val rct: ReactApplicationContext)
162
170
  reactApplicationContext,
163
171
  googlePayRequest,
164
172
  fun(data: Map<String, Any?>) {
173
+ NativeLog.debug("wallet_result_delivered", mapOf("wallet" to "google_pay", "status" to data["status"]?.toString()))
165
174
  callBack.invoke(
166
175
  Arguments.fromBundle(
167
176
  LaunchOptions(
@@ -199,6 +208,10 @@ class HyperModule internal constructor(private val rct: ReactApplicationContext)
199
208
  @ReactMethod
200
209
  override fun exitPaymentsheet(rootTag: Double, paymentResult: String, reset: Boolean) {
201
210
  val isFragment = PaymentSheetCallbackManager.executeCallback(paymentResult)
211
+ NativeLog.debug(
212
+ "exit_payment_sheet_resolved",
213
+ mapOf("isFragment" to isFragment, "activityPresent" to (currentActivity != null))
214
+ )
202
215
  (currentActivity as? FragmentActivity)?.let {
203
216
  if (isFragment) it.supportFragmentManager.findFragmentByTag("paymentSheet")
204
217
  ?.let { fragment ->
@@ -20,6 +20,7 @@ import io.hyperswitch.paymentsession.LaunchOptions
20
20
  import io.hyperswitch.react.HyperFragment
21
21
  import io.hyperswitch.react.HyperFragmentManager
22
22
  import io.hyperswitch.react.ReactNativeController
23
+ import io.hyperswitch.utils.StandardResult
23
24
  import kotlin.collections.orEmpty
24
25
 
25
26
  import kotlin.math.abs
@@ -186,13 +187,23 @@ open class PaymentWidgetView : FrameLayout {
186
187
  }
187
188
 
188
189
  fun confirmPayment(callback: (String) -> Unit) {
189
- this.fragment?.confirmPayment(callback)
190
+ val fragment = this.fragment
191
+ if (fragment != null) {
192
+ fragment.confirmPayment(callback)
193
+ } else {
194
+ /* Without this a detached fragment dropped the callback and the confirm promise hung. */
195
+ callback(fragmentNotAttachedFailure())
196
+ }
190
197
  }
191
198
 
192
-
193
199
  fun updatePaymentIntentInit(callback: () -> Unit) {
194
200
  if (isEligibleForUpdateIntent()) {
195
- this.fragment?.updatePaymentIntentInit(callback)
201
+ val fragment = this.fragment
202
+ if (fragment != null) {
203
+ fragment.updatePaymentIntentInit(callback)
204
+ } else {
205
+ callback()
206
+ }
196
207
  } else {
197
208
  callback()
198
209
  }
@@ -245,9 +256,21 @@ open class PaymentWidgetView : FrameLayout {
245
256
  billing: String?,
246
257
  callback: (String) -> Unit
247
258
  ) {
248
- this.fragment?.confirmCvcPayment(sdkAuthorization, paymentToken, billing, callback)
259
+ val fragment = this.fragment
260
+ if (fragment != null) {
261
+ fragment.confirmCvcPayment(sdkAuthorization, paymentToken, billing, callback)
262
+ } else {
263
+ callback(fragmentNotAttachedFailure())
264
+ }
249
265
  }
250
266
 
267
+ /* Mirrors StandardResult.Failed so the JS layer maps it to a failed result. */
268
+ private fun fragmentNotAttachedFailure() = StandardResult.Failed(
269
+ code = "FRAGMENT_NOT_ATTACHED",
270
+ message = "The payment widget's fragment is not attached",
271
+ error = Throwable("FRAGMENT_NOT_ATTACHED")
272
+ ).toJSONString()
273
+
251
274
  fun setSdkAuthorization(sdkAuthorization: String) {
252
275
  this.sdkAuthorization = sdkAuthorization
253
276
  if (isAttachedToWindow && !isSdkAuthorizationEmpty()) {