Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save nabilfreeman/e537bc59b57958ce6d79840dcdf36586 to your computer and use it in GitHub Desktop.
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.
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