Skip to content

Commit

Permalink
Bluetooth/Usb automatic reconnection (#516)
Browse files Browse the repository at this point in the history
* Add ReaderReconnectionListener in sdk

* Support iOS Bluetooth automatic reconnection.

* Fix type

* Add missing import.

* Add auto-reconnect toggle for QA testers.

* Fix syntax errors on iOS.

* Remove unnecessary parameter in reconnecting callbacks.
Update reader status while reconnecting fail.

* Fix e2e failing in auto-reconnect testing.

* Rename autoReconnect to autoReconnectOnUnexpectedDisconnect.

* Code optimization , expose cancelReaderReconnection method ;
Add test for RNBluetoothReaderListener;

* Simplified BluetoothConnectionConfiguration/UsbConnectionConfiguration

* Add emit event on reconnection callbacks.
  • Loading branch information
EricLin-BBpos authored Aug 16, 2023
1 parent 1870117 commit 22fe81a
Show file tree
Hide file tree
Showing 13 changed files with 309 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ enum class ReactNativeConstants(val listenerName: String) {
REPORT_UPDATE_PROGRESS("didReportReaderSoftwareUpdateProgress"),
START_INSTALLING_UPDATE("didStartInstallingUpdate"),
UPDATE_DISCOVERED_READERS("didUpdateDiscoveredReaders"),
START_READER_RECONNECT("didStartReaderReconnect"),
READER_RECONNECT_SUCCEED("didSucceedReaderReconnect"),
READER_RECONNECT_FAIL("didFailReaderReconnect"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.stripeterminalreactnative.ktx.connectReader
import com.stripeterminalreactnative.listener.RNBluetoothReaderListener
import com.stripeterminalreactnative.listener.RNDiscoveryListener
import com.stripeterminalreactnative.listener.RNHandoffReaderListener
import com.stripeterminalreactnative.listener.RNReaderReconnectionListener
import com.stripeterminalreactnative.listener.RNTerminalListener
import com.stripeterminalreactnative.listener.RNUsbReaderListener
import kotlinx.coroutines.CoroutineScope
Expand All @@ -62,6 +63,7 @@ class StripeTerminalReactNativeModule(reactContext: ReactApplicationContext) :
private var collectSetupIntentCancelable: Cancelable? = null
private var installUpdateCancelable: Cancelable? = null
private var readReusableCardCancelable: Cancelable? = null
private var cancelReaderConnectionCancellable: Cancelable? = null

private var paymentIntents: HashMap<String, PaymentIntent?> = HashMap()
private var setupIntents: HashMap<String, SetupIntent?> = HashMap()
Expand Down Expand Up @@ -221,11 +223,27 @@ class StripeTerminalReactNativeModule(reactContext: ReactApplicationContext) :
val locationId =
params.getString("locationId") ?: selectedReader.location?.id.orEmpty()

val autoReconnectOnUnexpectedDisconnect = if (discoveryMethod == DiscoveryMethod.BLUETOOTH_SCAN || discoveryMethod == DiscoveryMethod.USB) {
params.getBoolean("autoReconnectOnUnexpectedDisconnect") ?: false
} else false

val reconnectionListener = RNReaderReconnectionListener(context) {
cancelReaderConnectionCancellable = it
}
val connectedReader =
terminal.connectReader(discoveryMethod, selectedReader, locationId, listener)
promise.resolve(nativeMapOf {
putMap("reader", mapFromReader(connectedReader))
})
terminal.connectReader(
discoveryMethod,
selectedReader,
locationId,
autoReconnectOnUnexpectedDisconnect,
listener,
reconnectionListener
)
promise.resolve(
nativeMapOf {
putMap("reader", mapFromReader(connectedReader))
}
)
}
}
}
Expand Down Expand Up @@ -274,6 +292,12 @@ class StripeTerminalReactNativeModule(reactContext: ReactApplicationContext) :
terminal.disconnectReader(NoOpCallback(promise))
}

@ReactMethod
@Suppress("unused")
fun cancelReaderReconnection(promise: Promise) {
cancelOperation(promise, cancelReaderConnectionCancellable, "readerReconnection")
}

@ReactMethod
@Suppress("unused")
fun createPaymentIntent(params: ReadableMap, promise: Promise) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@ import com.stripe.stripeterminal.external.callable.BluetoothReaderListener
import com.stripe.stripeterminal.external.callable.HandoffReaderListener
import com.stripe.stripeterminal.external.callable.ReaderCallback
import com.stripe.stripeterminal.external.callable.ReaderListenable
import com.stripe.stripeterminal.external.callable.ReaderReconnectionListener
import com.stripe.stripeterminal.external.callable.UsbReaderListener
import com.stripe.stripeterminal.external.models.ConnectionConfiguration.BluetoothConnectionConfiguration
import com.stripe.stripeterminal.external.models.ConnectionConfiguration.EmbeddedConnectionConfiguration
import com.stripe.stripeterminal.external.models.ConnectionConfiguration.HandoffConnectionConfiguration
import com.stripe.stripeterminal.external.models.ConnectionConfiguration.InternetConnectionConfiguration
import com.stripe.stripeterminal.external.models.ConnectionConfiguration.LocalMobileConnectionConfiguration
import com.stripe.stripeterminal.external.models.ConnectionConfiguration.UsbConnectionConfiguration
import com.stripe.stripeterminal.external.models.DiscoveryMethod
import com.stripe.stripeterminal.external.models.DiscoveryMethod.BLUETOOTH_SCAN
import com.stripe.stripeterminal.external.models.DiscoveryMethod.EMBEDDED
import com.stripe.stripeterminal.external.models.DiscoveryMethod.HANDOFF
import com.stripe.stripeterminal.external.models.DiscoveryMethod.INTERNET
import com.stripe.stripeterminal.external.models.DiscoveryMethod.LOCAL_MOBILE
Expand Down Expand Up @@ -104,12 +103,17 @@ suspend fun Terminal.connectReader(
discoveryMethod: DiscoveryMethod,
reader: Reader,
locationId: String,
autoReconnectOnUnexpectedDisconnect: Boolean = false,
listener: ReaderListenable? = null,
reconnectionListener: ReaderReconnectionListener
): Reader = when (discoveryMethod) {
BLUETOOTH_SCAN -> {
if (listener is BluetoothReaderListener)
connectBluetoothReader(reader, BluetoothConnectionConfiguration(locationId), listener)
else connectBluetoothReader(reader, BluetoothConnectionConfiguration(locationId))
val connConfig = BluetoothConnectionConfiguration(locationId, autoReconnectOnUnexpectedDisconnect, reconnectionListener)
if (listener is BluetoothReaderListener) {
connectBluetoothReader(reader, connConfig, listener)
} else {
connectBluetoothReader(reader, connConfig)
}
}
LOCAL_MOBILE -> connectLocalMobileReader(reader, LocalMobileConnectionConfiguration(locationId))
INTERNET -> connectInternetReader(reader, InternetConnectionConfiguration())
Expand All @@ -119,9 +123,12 @@ suspend fun Terminal.connectReader(
else connectHandoffReader(reader, HandoffConnectionConfiguration(locationId))
}
USB -> {
if (listener is UsbReaderListener)
connectUsbReader(reader, UsbConnectionConfiguration(locationId), listener)
else connectUsbReader(reader, UsbConnectionConfiguration(locationId))
val connConfig = UsbConnectionConfiguration(locationId, autoReconnectOnUnexpectedDisconnect, reconnectionListener)
if (listener is UsbReaderListener) {
connectUsbReader(reader, connConfig, listener)
} else {
connectUsbReader(reader, connConfig)
}
}
else -> {
throw IllegalArgumentException("Unsupported discovery method: ${discoveryMethod}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.stripeterminalreactnative.listener

import com.facebook.react.bridge.ReactApplicationContext
import com.stripe.stripeterminal.external.callable.Cancelable
import com.stripe.stripeterminal.external.callable.ReaderReconnectionListener
import com.stripe.stripeterminal.external.models.Reader
import com.stripe.stripeterminal.external.models.TerminalException.TerminalErrorCode
import com.stripeterminalreactnative.ReactExtensions.sendEvent
import com.stripeterminalreactnative.ReactNativeConstants.*
import com.stripeterminalreactnative.mapFromReader
import com.stripeterminalreactnative.nativeMapOf

class RNReaderReconnectionListener(
private val context: ReactApplicationContext,
private val onReaderReconnectStarted: (cancelable: Cancelable?) -> Unit,
) : ReaderReconnectionListener {

override fun onReaderReconnectFailed(reader: Reader) {
context.sendEvent(READER_RECONNECT_FAIL.listenerName) {
putMap("error", nativeMapOf {
putString("code", TerminalErrorCode.UNEXPECTED_SDK_ERROR.toString())
putString("message", "Reader reconnect fail")
})
}
}

override fun onReaderReconnectStarted(reader: Reader, cancelReconnect: Cancelable) {
onReaderReconnectStarted(cancelReconnect)
context.sendEvent(START_READER_RECONNECT.listenerName) {
putMap("reader", mapFromReader(reader))
}
}

override fun onReaderReconnectSucceeded(reader: Reader) {
context.sendEvent(READER_RECONNECT_SUCCEED.listenerName) {
putMap("reader", mapFromReader(reader))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.stripeterminalreactnative.listener

import com.facebook.react.bridge.ReactApplicationContext
import com.stripe.stripeterminal.external.callable.Cancelable
import com.stripe.stripeterminal.external.models.Reader
import com.stripeterminalreactnative.*
import com.stripeterminalreactnative.ReactExtensions.sendEvent
import com.stripeterminalreactnative.ReactNativeConstants.READER_RECONNECT_FAIL
import com.stripeterminalreactnative.ReactNativeConstants.START_READER_RECONNECT
import com.stripeterminalreactnative.ReactNativeConstants.READER_RECONNECT_SUCCEED
import io.mockk.mockk
import io.mockk.verify
import org.junit.ClassRule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import kotlin.test.assertTrue

@RunWith(JUnit4::class)
class RNReaderReconnectionListenerTest {

companion object {
@ClassRule
@JvmField
val typeReplacer = ReactNativeTypeReplacementRule()
}

private val context = mockk<ReactApplicationContext>()

@Test
fun `should send onReaderReconnectFailed event`() {
val mockOnReaderReconnectStarted = mockk<(Cancelable?) -> Unit>(relaxed = true)
val reader = mockk<Reader>(relaxed = true)
val listener = RNReaderReconnectionListener(context,mockOnReaderReconnectStarted)
listener.onReaderReconnectFailed(reader)

verify(exactly = 1) {
context.sendEvent(READER_RECONNECT_FAIL.listenerName, any())
}

assertTrue(typeReplacer.sendEventSlot.captured.hasValue("error"))
}

@Test
fun `should send onReaderReconnectStarted event`() {
val mockOnReaderReconnectStarted = mockk<(Cancelable?) -> Unit>(relaxed = true)
val mockCancelable = mockk<Cancelable>()
val reader = mockk<Reader>(relaxed = true)
val listener = RNReaderReconnectionListener(context,mockOnReaderReconnectStarted)
listener.onReaderReconnectStarted(reader,mockCancelable)

verify(exactly = 1) { mockOnReaderReconnectStarted.invoke(mockCancelable) }
verify(exactly = 1) { context.sendEvent(START_READER_RECONNECT.listenerName, any()) }

assertTrue(typeReplacer.sendEventSlot.captured.hasValue("reader"))
}

@Test
fun `should send onReaderReconnectSucceeded event`() {
val mockOnReaderReconnectStarted = mockk<(Cancelable?) -> Unit>(relaxed = true)
val reader = mockk<Reader>(relaxed = true)
val listener = RNReaderReconnectionListener(context,mockOnReaderReconnectStarted)
listener.onReaderReconnectSucceeded(reader)

verify(exactly = 1) { context.sendEvent(READER_RECONNECT_SUCCEED.listenerName, any()) }

assertTrue(typeReplacer.sendEventSlot.captured.hasValue("reader"))
}
}
2 changes: 2 additions & 0 deletions dev-app/src/AppContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ export const AppContext = React.createContext<IAppContext>({
setAccount: (_k) => null,
lastSuccessfulChargeId: null,
setLastSuccessfulChargeId: (_id) => null,
autoReconnectOnUnexpectedDisconnect: false,
setAutoReconnectOnUnexpectedDisconnect: (_b) => null,
});
7 changes: 7 additions & 0 deletions dev-app/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export default function Root() {
const [lastSuccessfulChargeId, setLastSuccessfulChargeId] = useState<
string | null
>(null);
const [
autoReconnectOnUnexpectedDisconnect,
setAutoReconnectOnUnexpectedDisconnect,
] = useState<boolean | false>(false);

useEffect(() => {
// var is a string in CI
Expand Down Expand Up @@ -82,6 +86,9 @@ export default function Root() {
setAccount: onSelectAccount,
setLastSuccessfulChargeId: (id) => setLastSuccessfulChargeId(id),
lastSuccessfulChargeId,
autoReconnectOnUnexpectedDisconnect,
setAutoReconnectOnUnexpectedDisconnect: (b) =>
setAutoReconnectOnUnexpectedDisconnect(b),
}}
>
<StripeTerminalProvider
Expand Down
47 changes: 45 additions & 2 deletions dev-app/src/screens/DiscoverReadersScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import {
StyleSheet,
Text,
Expand All @@ -8,6 +14,7 @@ import {
View,
TouchableWithoutFeedback,
Platform,
Switch,
} from 'react-native';
import {
useStripeTerminal,
Expand All @@ -23,6 +30,7 @@ import ListItem from '../components/ListItem';
import List from '../components/List';

import type { RouteParamList } from '../App';
import { AppContext } from '../AppContext';

const SIMULATED_UPDATE_PLANS = [
'random',
Expand All @@ -39,7 +47,10 @@ export default function DiscoverReadersScreen() {
const [connectingReader, setConnectingReader] = useState<Reader.Type>();
const [showPicker, setShowPicker] = useState(false);
const pickerRef = useRef<Picker<string>>();

const {
autoReconnectOnUnexpectedDisconnect,
setAutoReconnectOnUnexpectedDisconnect,
} = useContext(AppContext);
const { simulated, discoveryMethod } = params;

const {
Expand Down Expand Up @@ -225,6 +236,7 @@ export default function DiscoverReadersScreen() {
const { reader: connectedReader, error } = await connectBluetoothReader({
reader,
locationId: selectedLocation?.id || reader?.location?.id,
autoReconnectOnUnexpectedDisconnect: autoReconnectOnUnexpectedDisconnect,
});

if (error) {
Expand Down Expand Up @@ -256,6 +268,7 @@ export default function DiscoverReadersScreen() {
const { reader: connectedReader, error } = await connectUsbReader({
reader,
locationId: selectedLocation?.id || reader?.location?.id,
autoReconnectOnUnexpectedDisconnect: autoReconnectOnUnexpectedDisconnect,
});

if (error) {
Expand Down Expand Up @@ -325,6 +338,36 @@ export default function DiscoverReadersScreen() {
</List>
)}

{!simulated &&
(discoveryMethod === 'bluetoothScan' ||
discoveryMethod === 'usb' ||
discoveryMethod === 'bluetoothProximity') && (
<List
bolded={false}
topSpacing={false}
title="AUTOMATIC RECONNECTION"
>
<ListItem
title="Enable Auto-Reconnect"
rightElement={
<Switch
testID="enable-automatic-reconnection"
value={autoReconnectOnUnexpectedDisconnect}
onValueChange={(value) => {
setAutoReconnectOnUnexpectedDisconnect(value);
}}
/>
}
/>

<Text style={styles.infoText}>
Automatic reconnection support for Bluetooth in iOS and Bluetooth
and USB in Android, where if the reader loses connection the SDK
will automatically attempts to reconnect to the reader.
</Text>
</List>
)}

<List
title="NEARBY READERS"
loading={discoveringLoading}
Expand Down
2 changes: 2 additions & 0 deletions dev-app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type IAppContext = {
}) => void;
lastSuccessfulChargeId: string | null;
setLastSuccessfulChargeId: (id: string) => void;
autoReconnectOnUnexpectedDisconnect: boolean | false;
setAutoReconnectOnUnexpectedDisconnect: (b: boolean) => void;
};

export type IShortAccount = {
Expand Down
Loading

0 comments on commit 22fe81a

Please sign in to comment.