import { NativeModules, PermissionsAndroid, Platform } from 'react-native'; /** * One populated SIM slot. * * `phoneNumber` is null far more often than not — see the note on `simCard`. * The slot and carrier are still worth reporting without it: on a dual-SIM * handset they are how a customer recognises which of their two lines the bank * has, which is the question the registration screen needs answered. */ export interface SimSubscription { /** Android's subscription id. Stable while the SIM stays in the phone. */ subscriptionId: number; /** Zero-based slot. Presented to the customer as SIM 1 / SIM 2. */ slotIndex: number; carrierName: string | null; /** The label the customer gave this SIM in Android settings, if any. */ displayName: string | null; /** Ten digits, or null when the carrier never provisioned the number. */ phoneNumber: string | null; } export interface SimInfo { /** A SIM is present and ready. False on a Wi-Fi-only tablet or with no SIM. */ hasSim: boolean; /** * READ_PHONE_STATE is granted, so the slots can be enumerated at all. * * This is NOT enough to read the numbers on Android 13 and up — see * `canReadNumbers`. Conflating the two is what used to send customers to a * branch over a permission the app had simply never asked for. */ hasPermission: boolean; /** * The MSISDN is readable, permission-wise. From Android 13 that means * READ_PHONE_NUMBERS specifically; below it, either phone permission will do. * * False here with `hasPermission` true is a real and common state: the slots * list fine and every number comes back null. */ canReadNumbers: boolean; /** Ten digits, or null when the carrier never provisioned the number. */ phoneNumber: string | null; carrierName: string | null; /** * Every active subscription. Empty without the phone-state permission. * More than one entry means the customer should be asked which line is theirs * rather than the app picking for them. */ sims: SimSubscription[]; } /** * What came of asking. * * `blocked` is the case worth handling separately: Android stops showing the * dialog after two refusals, so from then on requesting again returns * immediately and nothing visible happens. A button wired straight to a * re-request looks broken forever. The only way forward is app settings. */ export type PermissionOutcome = 'granted' | 'denied' | 'blocked'; const EMPTY: SimInfo = { hasSim: false, hasPermission: false, canReadNumbers: false, phoneNumber: null, carrierName: null, sims: [], }; /** * Deliberately loose: the payload is validated field by field below, and * declaring it as `SimInfo` would let TypeScript vouch for keys an older * native build never sends. */ interface SimCardNativeModule { getSimInfo(): Promise | null>; } /** Ten digits or nothing — the same contract the native side normalises to. */ const cleanNumber = (value: unknown): string | null => { if (typeof value !== 'string') { return null; } const digits = value.replace(/\D/g, ''); return digits.length === 10 ? digits : null; }; const toSubscription = (raw: unknown): SimSubscription | null => { if (typeof raw !== 'object' || raw === null) { return null; } const value = raw as Record; const slotIndex = Number(value.slotIndex); return { subscriptionId: Number(value.subscriptionId) || 0, slotIndex: Number.isFinite(slotIndex) ? slotIndex : 0, carrierName: typeof value.carrierName === 'string' ? value.carrierName : null, displayName: typeof value.displayName === 'string' ? value.displayName : null, phoneNumber: cleanNumber(value.phoneNumber), }; }; const native = NativeModules.SimCard as SimCardNativeModule | undefined; /** READ_PHONE_NUMBERS is the gate on the MSISDN from Android 13 onward. */ const NUMBERS_IS_REQUIRED = Platform.OS === 'android' && Number(Platform.Version) >= 33; /** The permission exists from Android 8, so there is no point asking below it. */ const NUMBERS_EXISTS = Platform.OS === 'android' && Number(Platform.Version) >= 26; /** * Reads the handset's own number so registration can offer it rather than make * the customer type it. * * A null `phoneNumber` is the ordinary case, not a failure: the MSISDN is only * readable if the carrier wrote it onto the SIM, and most Indian operators do * not. The caller must always keep a path that does not depend on it. */ export const simCard = { /** * Reads what the handset will say, and never prompts. * * Kept prompt-free on purpose: the registration screen calls this on mount, * and a permission dialog thrown up before the customer has read what the * screen is even for is the reason people refuse it. */ async read(): Promise { if (Platform.OS !== 'android' || !native) { return EMPTY; } try { const info = await native.getSimInfo(); const sims = Array.isArray(info?.sims) ? info.sims .map(toSubscription) .filter((entry): entry is SimSubscription => entry !== null) // Slot order, so "SIM 1" in the UI is the handset's SIM 1. The // platform does not guarantee the list comes back ordered. .sort((a, b) => a.slotIndex - b.slotIndex) : []; const hasPermission = Boolean(info?.hasPermission); return { hasSim: Boolean(info?.hasSim), hasPermission, /* * A native build older than this field simply omits it, which happens * on any JS-only reload against an installed APK. * * Reading a missing key as `false` would be worse than useless: the * screen would show "one more permission needed", the button would ask * for a permission already granted, the re-read would come back false * again, and the customer would be stuck in that loop with no way out. * Falling back to `hasPermission` reproduces the old behaviour instead, * which is wrong in the same way it was always wrong but never traps * anyone. */ canReadNumbers: typeof info?.canReadNumbers === 'boolean' ? info.canReadNumbers : hasPermission, phoneNumber: cleanNumber(info?.phoneNumber), carrierName: typeof info?.carrierName === 'string' ? info.carrierName : null, sims, }; } catch { return EMPTY; } }, /** * Asks for both phone permissions and reports what happened. * * Both are requested together, always. Asking for only what is missing * sounds tidier but produces a second dialog on a later run, and two separate * phone-permission prompts in one flow is what makes an app look like it is * fishing. * * "Granted" means the app can do the job the customer agreed to: enumerate * the slots, and — on Android 13 and up, where it is a separate grant — read * the numbers. A partial grant is reported as `denied`, not success, because * a slot list with every number null is not a usable registration screen. */ async requestPermission(): Promise { if (Platform.OS !== 'android') { return 'denied'; } try { const wanted: string[] = [PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE]; if (NUMBERS_EXISTS) { wanted.push(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS); } const result = await PermissionsAndroid.requestMultiple( wanted as Parameters[0], ); const stateStatus = result[PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE]; const numbersStatus = NUMBERS_EXISTS ? result[PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS] : PermissionsAndroid.RESULTS.GRANTED; const needed = NUMBERS_IS_REQUIRED ? [stateStatus, numbersStatus] : [stateStatus]; if (needed.every((status) => status === PermissionsAndroid.RESULTS.GRANTED)) { return 'granted'; } // Checked across both, not only the ones we strictly need: once either is // in never-ask-again the dialog stops appearing, and a "try again" button // would be dead from then on. if ( [stateStatus, numbersStatus].some( (status) => status === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN, ) ) { return 'blocked'; } return 'denied'; } catch { return 'denied'; } }, /** * Prompts, then re-reads. For a button the customer pressed knowing what it * would do — not for mount. */ async requestAndRead(): Promise<{ outcome: PermissionOutcome; info: SimInfo }> { const outcome = await this.requestPermission(); return { outcome, info: await this.read() }; }, };