Created
June 12, 2024 22:13
-
-
Save nabilfreeman/e537bc59b57958ce6d79840dcdf36586 to your computer and use it in GitHub Desktop.
A non-hooks implementation of @stripe/stripe-react-native-terminal. Extremely cursed. This is a proof of concept why you should not do things this way.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
cancelDiscovering, | |
discoverReaders, | |
initialize, | |
setConnectionToken, | |
} from '@stripe/stripe-terminal-react-native/src/functions'; | |
import { | |
reportProblemSilently, | |
showSeriousProblem, | |
} from '../../util/errorHandling'; | |
import { | |
CommonError, | |
FETCH_TOKEN_PROVIDER, | |
FINISH_DISCOVERING_READERS, | |
Reader, | |
StripeError, | |
UPDATE_DISCOVERED_READERS, | |
requestNeededAndroidPermissions, | |
} from '@stripe/stripe-terminal-react-native'; | |
import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; | |
import { isDevice } from 'expo-device'; | |
import { EmitterSubscription } from 'react-native'; | |
import { noop } from 'lodash'; | |
type TapToPayBaseProps = { | |
logger?: (message: string) => void; | |
}; | |
/** | |
* Stripe Terminal SDK functions can throw at invocation stage, or they can throw by returning an object with an error property. | |
* This function wraps the invocation of a Stripe Terminal SDK function and ensures that any errors are thrown in a consistent way. | |
* @param fn The function to run. If you need to pass arguments, wrap it in an async arrow function that returns the method you are invoking. | |
*/ | |
async function runAndHandleError( | |
fn: () => Promise<{ | |
error?: StripeError<CommonError> | undefined; | |
}>, | |
) { | |
// Stripe throws errors in two different ways. Yes, wow. | |
try { | |
const { error } = await fn(); | |
if (error) throw error; | |
} catch (error) { | |
// Now we can reliably throw the error back out in a consistent way. | |
throw error; | |
} | |
} | |
export function isTapToPaySupported() { | |
// TODO check operating system version etc if your app is not specifically engineered to support TTPOI | |
return true; | |
} | |
// Singleton where we store the reader object | |
let tapToPayReader: Reader.Type | undefined = undefined; | |
function setReader(reader: Reader.Type, logger?: TapToPayBaseProps['logger']) { | |
tapToPayReader = reader; | |
logger?.( | |
`Reader found: ${tapToPayReader.id} at ${tapToPayReader.locationId}`, | |
); | |
} | |
let terminalEventEmitter: NativeEventEmitter | undefined = undefined; | |
/** | |
* Launches the Stripe Tap To Pay SDK. This should be called as early as possible in the app lifecycle for the sake of Apple's guidelines. | |
* This does NOT throw an error if Tap To Pay is not supported on the device. | |
*/ | |
export async function initializeTapToPayIfSupported( | |
props: TapToPayBaseProps & {}, | |
) { | |
if (!isTapToPaySupported()) { | |
return; | |
} | |
props.logger?.('Connecting...'); | |
const result = await initialize({ | |
logLevel: 'verbose', | |
}); | |
markTapToPayAsLoaded(); | |
props.logger?.('Connected.'); | |
// If the SDK returned a reader object, store it in the singleton for fast access later. | |
if (result.reader) { | |
setReader(result.reader, props.logger); | |
} | |
attachListeners(); | |
props.logger?.('Listeners attached.'); | |
} | |
/** | |
* Ensures that the Tap To Pay SDK is loaded. If it is, then this function immediately resolves. | |
* If it is not, then it will load the SDK and resolve once it has been loaded. | |
* This should be used in any code that requires the Tap To Pay SDK to be loaded, and errors should be handled gracefully. | |
*/ | |
export async function ensureTapToPayIsLoaded(props: TapToPayBaseProps & {}) { | |
const initialCheck = isTapToPayLoaded(); | |
if (initialCheck) { | |
props.logger?.('SDK is already loaded.'); | |
return; | |
} | |
// Below this means it's not loaded yet. | |
// Developers beware... This time, you will get a crash if you call this on an unsupported device. | |
if (isTapToPaySupported()) { | |
throw new Error('Tap to Pay is not supported on this device.'); | |
} | |
// If the Tap To Pay SDK has not been loaded, load it now. | |
await initializeTapToPayIfSupported(props); | |
const finalCheck = isTapToPayLoaded(); | |
if (finalCheck) { | |
props.logger?.('SDK is now loaded.'); | |
} else { | |
// If it still hasn't been loaded, throw an error. | |
if (!isTapToPayLoaded()) { | |
await reportProblemSilently({ | |
title: 'Tap to Pay failed to load', | |
error: new Error( | |
'Tap to Pay did not load after attempted initialization.', | |
), | |
}); | |
throw new Error('Sorry, Tap to Pay is not currently available.'); | |
} | |
} | |
} | |
/** | |
* Tells the native SDK what Terminal session it should be scoped to. | |
* This will be used to identify the session on the next function call in the Tap To Pay flow. | |
* These tokens are SINGLE USE. | |
* @param {string} secret The secret generated server-side that identifies this Tap To Pay session. | |
*/ | |
async function setTerminalSession(secret: string) { | |
await ensureTapToPayIsLoaded({}); | |
console.log('💳 Setting Terminal secret...'); | |
await setConnectionToken(secret); | |
console.log('💳 Terminal secret set.'); | |
} | |
/** | |
* Gets the reader object, creating it if it doesn't exist. | |
* @returns The reader object. | |
*/ | |
export async function searchForReader( | |
props: TapToPayBaseProps & { | |
forceRefresh?: boolean; | |
timeout?: number; | |
}, | |
) { | |
await ensureTapToPayIsLoaded(props); | |
// Skip the search if we already found the reader. | |
if (tapToPayReader && !props.forceRefresh) { | |
return tapToPayReader; | |
} | |
const eventEmitter = new NativeEventEmitter( | |
NativeModules.StripeTerminalReactNative, | |
); | |
// The search will end after a maximum of 60 seconds by default. | |
const readerDiscoveryTimeout = props.timeout ?? 60000; | |
// This initiates the search for readers, and reads the first one it finds. | |
// It's weirdly written because the RN SDK returns information about the readers asynchronously, in an event driven way. | |
await new Promise<void>((resolve, reject) => { | |
let readersListener: EmitterSubscription | undefined = undefined; | |
let finishListener: EmitterSubscription | undefined = undefined; | |
let didFinish = false; | |
// This resolves and cleans up this big promise. | |
// Important to note that the reader singleton may still be undefined, if it was not found, or if the search timed out. | |
const endSearch = () => { | |
if (!didFinish) { | |
readersListener?.remove(); | |
finishListener?.remove(); | |
didFinish = true; | |
void runAndHandleError(async () => cancelDiscovering()).catch( | |
noop, | |
); | |
resolve(); | |
} | |
}; | |
// The search will end when the timeout is reached. | |
setTimeout(() => { | |
props.logger?.('Reader search timed out.'); | |
endSearch(); | |
}, readerDiscoveryTimeout); | |
// The SDK sends back information about the readers via an event emitter. | |
readersListener = eventEmitter.addListener( | |
UPDATE_DISCOVERED_READERS, | |
({ readers }: { readers: Reader.Type[] }) => { | |
if (readers.length) { | |
setReader(readers[0]); | |
endSearch(); | |
} | |
}, | |
); | |
finishListener = eventEmitter.addListener( | |
FINISH_DISCOVERING_READERS, | |
() => { | |
// If the SDK says it's done, then we're done. | |
props.logger?.('Reader search finished.'); | |
endSearch(); | |
}, | |
); | |
const shouldUseSimulatedReader = !isDevice; | |
if (shouldUseSimulatedReader) { | |
props.logger?.('Looking for simulated card readers...'); | |
} else { | |
props.logger?.('Looking for readers...'); | |
} | |
// This resolves instantly. Readers are discovered asynchronously and returned via the event emitter. | |
void runAndHandleError(async () => | |
discoverReaders({ | |
// This tells the SDK we want the device's internal reader (i.e. Tap to Pay) | |
discoveryMethod: 'localMobile', | |
// If we're on a simulator, we must use a simulated reader, otherwise it throws an error | |
simulated: shouldUseSimulatedReader, | |
}), | |
).catch(reject); | |
}); | |
if (!tapToPayReader) { | |
throw new Error('Reader not found.'); | |
} | |
return tapToPayReader; | |
} | |
/** | |
* Ensures that the necessary permissions are granted for Tap to Pay to function. | |
* @returns {boolean} True if the permissions are granted, false if they are not. | |
*/ | |
export async function ensurePermissionsAreGranted( | |
props: TapToPayBaseProps & {}, | |
) { | |
props.logger?.('Ensuring permissions...'); | |
try { | |
if (Platform.OS === 'android') { | |
const { error } = await requestNeededAndroidPermissions({ | |
accessFineLocation: { | |
title: 'Location Permission', | |
message: | |
'To use Tap to Pay, you need to allow access to your current location.', | |
buttonPositive: 'Accept', | |
}, | |
}); | |
if (error) throw error; | |
} | |
return true; | |
} catch (error) { | |
showSeriousProblem({ | |
title: `Tap to Pay permission not granted`, | |
error, | |
}); | |
return false; | |
} | |
} | |
let readersSingletonListener: EmitterSubscription | undefined = undefined; | |
let tokenSingletonListener: EmitterSubscription | undefined = undefined; | |
/** | |
* Attaches listeners to the Tap to Pay SDK. Some care has been taken to make this idempotent, but try not to call it multiple times. | |
*/ | |
function attachListeners() { | |
if (!terminalEventEmitter) { | |
terminalEventEmitter = new NativeEventEmitter( | |
NativeModules.StripeTerminalReactNative, | |
); | |
} | |
readersSingletonListener?.remove(); | |
readersSingletonListener = terminalEventEmitter.addListener( | |
UPDATE_DISCOVERED_READERS, | |
({ readers }: { readers: Reader.Type[] }) => { | |
if (readers.length) { | |
setReader(readers[0]); | |
} | |
}, | |
); | |
// This is REALLY important. Every single Terminal SDK function call requires a brand new valid session token. | |
// The native SDK will emit this event when it needs a new token, and it must be given one within 60 seconds. | |
tokenSingletonListener?.remove(); | |
tokenSingletonListener = terminalEventEmitter.addListener( | |
FETCH_TOKEN_PROVIDER, | |
async () => { | |
try { | |
console.log('💳 Fetching new session token...'); | |
// Fetch a new session from API | |
const result = await getSingleUseConnectionTokenStringFromAPI(); | |
await setTerminalSession(result.secret); | |
} catch (error) { | |
// Don't show a problem here - if the session could not be created, then the tap to pay button will simply be unavailable. | |
console.log(`💳 Error fetching new session token:`, error); | |
} | |
}, | |
); | |
} | |
/** | |
* A singleton variable to track the load state of Tap To Pay in the application. | |
* @type {boolean} | |
*/ | |
let TapToPaySingletonLoaded: boolean = false; | |
/** | |
* Marks TapToPay as loaded by setting the `TapToPaySingletonLoaded` flag to `true`. | |
* This function should be called once Tap To Pay has been successfully initialized and loaded. | |
*/ | |
export function markTapToPayAsLoaded() { | |
TapToPaySingletonLoaded = true; | |
} | |
/** | |
* Checks if TapToPay has been marked as loaded. | |
* @returns {boolean} `true` if Tap To Pay has been loaded and marked as such, otherwise `false`. | |
*/ | |
export function isTapToPayLoaded() { | |
return TapToPaySingletonLoaded; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment