Files
CBS-Mobile-App/src/services/device/simCard.ts
T

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() };
},
};