Update CBS Mobile App codebase
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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() };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user