Skip to content

Commit 1c44f0d

Browse files
feat: add Samsung Pay availability check and related module implementation (#45)
1 parent a04a71e commit 1c44f0d

8 files changed

Lines changed: 286 additions & 0 deletions

File tree

android/src/main/java/com/moyasarsdk/RTNMoyasarPackage.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext)
2323

2424
if (name.equals(RTNDeviceLanguageImpl.NAME)) {
2525
return new RTNDeviceLanguage(reactContext);
26+
} else if (name.equals(RTNSamsungPayModuleImpl.NAME)) {
27+
return new RTNSamsungPay(reactContext);
2628
} else {
2729
return null;
2830
}
@@ -54,6 +56,19 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() {
5456
isTurboModule // isTurboModule
5557
)
5658
);
59+
60+
moduleInfos.put(
61+
RTNSamsungPayModuleImpl.NAME,
62+
new ReactModuleInfo(
63+
RTNSamsungPayModuleImpl.NAME,
64+
RTNSamsungPayModuleImpl.NAME,
65+
false, // canOverrideExistingModule
66+
false, // needsEagerInit
67+
false, // hasConstants
68+
false, // isCxxModule
69+
isTurboModule // isTurboModule
70+
)
71+
);
5772
return moduleInfos;
5873
};
5974
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.moyasarsdk
2+
3+
import android.os.Bundle
4+
import com.facebook.react.bridge.Promise
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.samsung.android.sdk.samsungpay.v2.PartnerInfo
7+
import com.samsung.android.sdk.samsungpay.v2.SamsungPay
8+
import com.samsung.android.sdk.samsungpay.v2.SpaySdk
9+
import com.samsung.android.sdk.samsungpay.v2.StatusListener
10+
import java.util.concurrent.atomic.AtomicBoolean
11+
12+
/**
13+
* Shared implementation for the Samsung Pay native module.
14+
*
15+
* Exposes an availability check so apps building their own UI can decide
16+
* whether to show a Samsung Pay option before rendering the Samsung Pay button.
17+
*/
18+
class RTNSamsungPayModuleImpl {
19+
20+
companion object {
21+
const val NAME = "RTNSamsungPay"
22+
}
23+
24+
/**
25+
* Resolves `true` only when Samsung Pay is ready to process a payment
26+
* (`SPAY_READY`). Every other status (not supported, not set up, temporarily
27+
* not allowed, or any failure) resolves `false`. The promise never rejects,
28+
* so callers get a simple boolean.
29+
*/
30+
fun isSamsungPayAvailable(
31+
reactContext: ReactApplicationContext,
32+
serviceId: String?,
33+
promise: Promise
34+
) {
35+
Logger.d("MoyasarSDK", "Checking Samsung Pay availability...")
36+
37+
// Guarantees the promise is settled exactly once even if the Samsung Pay
38+
// SDK were to invoke the listener more than once, which would otherwise
39+
// throw when resolving an already-settled promise.
40+
val settled = AtomicBoolean(false)
41+
val resolveOnce = { isAvailable: Boolean ->
42+
if (settled.compareAndSet(false, true)) {
43+
promise.resolve(isAvailable)
44+
}
45+
}
46+
47+
if (serviceId.isNullOrBlank()) {
48+
Logger.e("MoyasarSDK", "serviceId is null or blank, cannot check Samsung Pay availability")
49+
resolveOnce(false)
50+
return
51+
}
52+
53+
try {
54+
val bundle = Bundle()
55+
bundle.putString(SpaySdk.PARTNER_SERVICE_TYPE, SpaySdk.ServiceType.INAPP_PAYMENT.toString())
56+
57+
val partnerInfo = PartnerInfo(serviceId, bundle)
58+
59+
// A status query only needs the application context; using it (rather
60+
// than an Activity) avoids leaks and works even when no Activity is
61+
// resumed. Fall back defensively if it is somehow unavailable.
62+
val context = reactContext.applicationContext ?: reactContext
63+
val samsungPay = SamsungPay(context, partnerInfo)
64+
65+
samsungPay.getSamsungPayStatus(object : StatusListener {
66+
// Capturing `samsungPay` keeps it (and the service binding it
67+
// owns) alive until this asynchronous callback fires. Using a
68+
// per-call local rather than a shared field also keeps
69+
// concurrent availability checks independent.
70+
@Suppress("unused")
71+
private val retained = samsungPay
72+
73+
override fun onSuccess(status: Int, extras: Bundle) {
74+
val isReady = status == SpaySdk.SPAY_READY
75+
Logger.d("MoyasarSDK", "Samsung Pay status: $status, ready: $isReady")
76+
resolveOnce(isReady)
77+
}
78+
79+
override fun onFail(errorCode: Int, extras: Bundle) {
80+
Logger.w("MoyasarSDK", "Samsung Pay status check failed with error code: $errorCode")
81+
resolveOnce(false)
82+
}
83+
})
84+
} catch (ex: Exception) {
85+
Logger.e("MoyasarSDK", "Checking Samsung Pay availability failed", ex)
86+
resolveOnce(false)
87+
}
88+
}
89+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.moyasarsdk;
2+
3+
import androidx.annotation.NonNull;
4+
import com.facebook.react.bridge.Promise;
5+
import com.facebook.react.bridge.ReactApplicationContext;
6+
7+
import com.moyasarsdk.NativeRTNSamsungPaySpec;
8+
9+
public class RTNSamsungPay extends NativeRTNSamsungPaySpec {
10+
11+
private final RTNSamsungPayModuleImpl implementation;
12+
13+
public RTNSamsungPay(ReactApplicationContext reactContext) {
14+
super(reactContext);
15+
16+
this.implementation = new RTNSamsungPayModuleImpl();
17+
}
18+
19+
@Override
20+
@NonNull
21+
public String getName() {
22+
return RTNSamsungPayModuleImpl.NAME;
23+
}
24+
25+
@Override
26+
public void isSamsungPayAvailable(String serviceId, Promise promise) {
27+
implementation.isSamsungPayAvailable(getReactApplicationContext(), serviceId, promise);
28+
}
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.moyasarsdk;
2+
3+
import androidx.annotation.NonNull;
4+
import com.facebook.react.bridge.Promise;
5+
import com.facebook.react.bridge.ReactApplicationContext;
6+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
7+
import com.facebook.react.bridge.ReactMethod;
8+
9+
public class RTNSamsungPay extends ReactContextBaseJavaModule {
10+
11+
private final RTNSamsungPayModuleImpl implementation;
12+
13+
public RTNSamsungPay(ReactApplicationContext reactContext) {
14+
super(reactContext);
15+
16+
this.implementation = new RTNSamsungPayModuleImpl();
17+
}
18+
19+
@Override
20+
@NonNull
21+
public String getName() {
22+
return RTNSamsungPayModuleImpl.NAME;
23+
}
24+
25+
@ReactMethod
26+
public void isSamsungPayAvailable(String serviceId, Promise promise) {
27+
implementation.isSamsungPayAvailable(getReactApplicationContext(), serviceId, promise);
28+
}
29+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Platform } from 'react-native';
2+
import { isSamsungPayAvailable } from '../../helpers/samsung_pay_availability';
3+
import NativeRTNSamsungPay from '../../specs/NativeRTNSamsungPay';
4+
5+
jest.mock('../../specs/NativeRTNSamsungPay', () => ({
6+
__esModule: true,
7+
default: { isSamsungPayAvailable: jest.fn() },
8+
}));
9+
10+
describe('isSamsungPayAvailable', () => {
11+
const serviceId = 'ea810dafb758408fa530b1';
12+
const nativeModule = NativeRTNSamsungPay as unknown as {
13+
isSamsungPayAvailable: jest.Mock;
14+
};
15+
16+
beforeEach(() => {
17+
jest.clearAllMocks();
18+
Platform.OS = 'android';
19+
// The helper logs via `console.error` in dev; keep test output clean.
20+
jest.spyOn(console, 'error').mockImplementation(() => {});
21+
});
22+
23+
afterEach(() => {
24+
jest.restoreAllMocks();
25+
});
26+
27+
it('returns the native result when Samsung Pay is ready', async () => {
28+
nativeModule.isSamsungPayAvailable.mockResolvedValue(true);
29+
30+
await expect(isSamsungPayAvailable(serviceId)).resolves.toBe(true);
31+
expect(nativeModule.isSamsungPayAvailable).toHaveBeenCalledWith(serviceId);
32+
});
33+
34+
it('returns false when the native module reports not ready', async () => {
35+
nativeModule.isSamsungPayAvailable.mockResolvedValue(false);
36+
37+
await expect(isSamsungPayAvailable(serviceId)).resolves.toBe(false);
38+
});
39+
40+
it('returns false on non-Android platforms without calling native', async () => {
41+
Platform.OS = 'ios';
42+
43+
await expect(isSamsungPayAvailable(serviceId)).resolves.toBe(false);
44+
expect(nativeModule.isSamsungPayAvailable).not.toHaveBeenCalled();
45+
});
46+
47+
it('returns false and skips the native call when serviceId is blank', async () => {
48+
await expect(isSamsungPayAvailable(' ')).resolves.toBe(false);
49+
expect(nativeModule.isSamsungPayAvailable).not.toHaveBeenCalled();
50+
});
51+
52+
it('returns false when the native call rejects', async () => {
53+
nativeModule.isSamsungPayAvailable.mockRejectedValue(new Error('boom'));
54+
55+
await expect(isSamsungPayAvailable(serviceId)).resolves.toBe(false);
56+
});
57+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Platform } from 'react-native';
2+
import NativeRTNSamsungPay from '../specs/NativeRTNSamsungPay';
3+
import { debugLog, errorLog } from './debug_log';
4+
5+
/**
6+
* Checks whether Samsung Pay is available and ready (set up and active) on the
7+
* current device.
8+
*
9+
* Returns `true` only when Samsung Pay is fully ready to process a payment
10+
* (Samsung's `SPAY_READY` status). Returns `false` on non-Android platforms,
11+
* when Samsung Pay is not supported, or when it is supported but not yet set
12+
* up / activated by the user.
13+
*
14+
* Use this to decide whether to show a Samsung Pay option in your own custom
15+
* payment UI before rendering the `SamsungPay` button.
16+
*
17+
* @param serviceId - The Samsung Pay service ID generated in the Samsung
18+
* merchant dashboard (the same `serviceId` used in `SamsungPayConfig`).
19+
* @returns A promise resolving to `true` if Samsung Pay is ready, otherwise
20+
* `false`.
21+
*/
22+
export async function isSamsungPayAvailable(
23+
serviceId: string
24+
): Promise<boolean> {
25+
if (Platform.OS !== 'android') {
26+
debugLog(
27+
'Moyasar SDK: Samsung Pay is only available on Android, returning false'
28+
);
29+
return false;
30+
}
31+
32+
if (!serviceId || serviceId.trim().length === 0) {
33+
errorLog(
34+
'Moyasar SDK: A `serviceId` is required to check Samsung Pay availability'
35+
);
36+
return false;
37+
}
38+
39+
try {
40+
if (!NativeRTNSamsungPay) {
41+
errorLog('Moyasar SDK: Samsung Pay native module is not available');
42+
return false;
43+
}
44+
45+
return await NativeRTNSamsungPay.isSamsungPayAvailable(serviceId);
46+
} catch (error) {
47+
errorLog(`Moyasar SDK: Failed to check Samsung Pay availability, ${error}`);
48+
return false;
49+
}
50+
}

src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
createToken,
2222
sendOtp,
2323
} from './services/payment_service';
24+
export { isSamsungPayAvailable } from './helpers/samsung_pay_availability';
2425
export * from './models/payment_type';
2526
export * from './models/api/sources/payment_request_source';
2627
export * from './models/api/sources/payment_response_source';

src/specs/NativeRTNSamsungPay.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type TurboModule, TurboModuleRegistry } from 'react-native';
2+
3+
export interface Spec extends TurboModule {
4+
isSamsungPayAvailable(serviceId: string): Promise<boolean>;
5+
}
6+
7+
let instance: Spec | null = null;
8+
9+
const getInstance = (): Spec | null => {
10+
if (!instance) {
11+
instance = TurboModuleRegistry.get<Spec>('RTNSamsungPay');
12+
}
13+
return instance;
14+
};
15+
16+
export default getInstance();

0 commit comments

Comments
 (0)