244 lines
8.6 KiB
TypeScript
244 lines
8.6 KiB
TypeScript
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<Record<string, unknown> | 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<string, unknown>;
|
|
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<SimInfo> {
|
|
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<PermissionOutcome> {
|
|
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<typeof PermissionsAndroid.requestMultiple>[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() };
|
|
},
|
|
};
|