Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+6
View File
@@ -3,6 +3,12 @@ import axios from 'axios';
import { env } from '../../config/env';
import { attachInterceptors } from './interceptors';
/**
* Single client for the CBS backend. The response interceptor unwraps the
* `{ success, message, data }` envelope, so callers read `response.data` as the
* endpoint payload and a `success: false` body arrives as a thrown
* `CbsApiError` carrying the code to switch on.
*/
export const apiClient = axios.create({
baseURL: env.apiBaseUrl,
timeout: 15000,
+271
View File
@@ -0,0 +1,271 @@
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
/**
* Dev-only request/response logging for the CBS client.
*
* Every request is printed as a runnable `curl`, so a failure can be reproduced
* against the backend outside the app — which is usually the fastest way to
* settle whether a problem is the handset, the client, or the server.
*
* ── WHY THIS IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ───────────────────────
* The curl carries whatever the request carried: the `Authorization` header
* with a live session token, and the RSA-encrypted MPIN blob on the
* registration calls. That is exactly what makes it useful, and exactly why it
* must never run in a release build — a bank's production logs are not a place
* to put a working credential. There is deliberately no runtime flag to turn
* this on outside development; `__DEV__` is compiled out, so none of this code
* ships.
*/
/** Set false to silence the log without unpicking the interceptors. */
const ENABLED = __DEV__;
/**
* Per-request bookkeeping, so a response can be matched to its request in a
* busy log and timed.
*
* Keyed on the config object in a WeakMap rather than stashed on the config
* itself: an extra property would travel with the request, need a TypeScript
* module augmentation, and could collide with axios's own fields. The entry
* disappears on its own once axios drops the config.
*/
const inFlight = new WeakMap<object, { id: string; startedAt: number }>();
let counter = 0;
const nextId = () => {
counter += 1;
return counter.toString(16).padStart(3, '0');
};
/** Shell-safe single-quoting: the only escape a POSIX single-quoted string has. */
const quote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
/**
* Headers as a flat map.
*
* Axios 1.x hands over an `AxiosHeaders` instance whose `toJSON()` gives the
* flattened set. Older shapes nest per-method groups (`common`, `get`, `post`)
* that are not real headers, so anything non-primitive is dropped rather than
* printed as `[object Object]` in a curl that would then not run.
*/
const readHeaders = (config: InternalAxiosRequestConfig): Record<string, string> => {
const raw = config.headers as unknown;
if (raw && typeof (raw as { toJSON?: unknown }).toJSON === 'function') {
const json = (raw as { toJSON: () => Record<string, unknown> }).toJSON();
return Object.fromEntries(
Object.entries(json)
.filter(([, value]) => typeof value === 'string' || typeof value === 'number')
.map(([key, value]) => [key, String(value)]),
);
}
if (raw && typeof raw === 'object') {
return Object.fromEntries(
Object.entries(raw as Record<string, unknown>)
.filter(([, value]) => typeof value === 'string' || typeof value === 'number')
.map(([key, value]) => [key, String(value)]),
);
}
return {};
};
/** Absolute URL, query string included, so the curl needs no editing. */
const readUrl = (config: InternalAxiosRequestConfig): string => {
const base = (config.baseURL ?? '').replace(/\/+$/, '');
const path = config.url ?? '';
const url = path.startsWith('http') ? path : `${base}/${path.replace(/^\/+/, '')}`;
const params = config.params as Record<string, unknown> | undefined;
if (!params) {
return url;
}
const query = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&');
return query ? `${url}?${query}` : url;
};
/**
* The body as it will go on the wire.
*
* At request-interceptor time axios has not run `transformRequest` yet, so
* `config.data` is usually still the object the caller passed — hence the
* stringify. It is already a string when a caller serialised it themselves.
*/
const readBody = (config: InternalAxiosRequestConfig): string | undefined => {
const { data } = config;
if (data === undefined || data === null) {
return undefined;
}
if (typeof data === 'string') {
return data;
}
try {
return JSON.stringify(data);
} catch {
return String(data);
}
};
const toCurl = (config: InternalAxiosRequestConfig): string => {
const method = (config.method ?? 'get').toUpperCase();
const lines = [`curl -i -X ${method} ${quote(readUrl(config))}`];
for (const [key, value] of Object.entries(readHeaders(config))) {
lines.push(`-H ${quote(`${key}: ${value}`)}`);
}
const body = readBody(config);
if (body !== undefined) {
lines.push(`--data ${quote(body)}`);
}
// Continuations, so the whole thing can be pasted into a terminal as one
// command instead of being reassembled by hand.
return lines.join(' \\\n ');
};
/** A short label for the log header — method and path, without the origin. */
const label = (config: InternalAxiosRequestConfig | undefined) => {
const method = (config?.method ?? '???').toUpperCase();
const path = config?.url ?? '?';
return `${method} ${path}`;
};
const pretty = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const elapsed = (config: object | undefined) => {
const entry = config ? inFlight.get(config) : undefined;
return entry ? `${Date.now() - entry.startedAt}ms` : '?ms';
};
const idOf = (config: object | undefined) => {
const entry = config ? inFlight.get(config) : undefined;
return entry ? entry.id : '---';
};
export const apiLogger = {
request(config: InternalAxiosRequestConfig) {
if (!ENABLED) {
return;
}
const id = nextId();
inFlight.set(config, { id, startedAt: Date.now() });
console.log(`\n┌── API → #${id} ${label(config)}\n${toCurl(config)}\n└──`);
},
/**
* Called with the envelope still intact — before the response interceptor
* unwraps `data` — so what is printed is what the server actually sent.
*/
response(response: AxiosResponse) {
if (!ENABLED) {
return;
}
console.log(
`\n┌── API ← #${idOf(response.config)} ${response.status} ${label(
response.config as InternalAxiosRequestConfig,
)} (${elapsed(response.config)})\n${pretty(response.data)}\n└──`,
);
},
/**
* A response that arrived and said no: either an HTTP error, or a 2xx whose
* envelope carried `success: false`.
*/
failure(response: AxiosResponse | undefined, note?: string) {
if (!ENABLED) {
return;
}
const head = `┌── API ✗ #${idOf(response?.config)} ${response?.status ?? '???'} ${label(
response?.config as InternalAxiosRequestConfig | undefined,
)} (${elapsed(response?.config)})`;
console.warn(
`\n${head}${note ? `\n${note}` : ''}\n${pretty(response?.data ?? '(no body)')}\n└──`,
);
},
/**
* A call that does not go through axios at all.
*
* The two statement PDF endpoints answer a binary body rather than the JSON
* envelope, so they are fetched outside `apiClient` and would otherwise be
* the only requests in the app invisible to this log — which is the worst
* place to have a blind spot, since a download that silently does nothing is
* indistinguishable from a button that is not wired up.
*
* The curl is written by hand here rather than derived from a config,
* because there is no axios config to derive it from. Same rule as above:
* the header carries a live token, and none of this ships.
*/
rawRequest(method: string, url: string, note?: string) {
if (!ENABLED) {
return;
}
console.log(
`\n┌── API → #${nextId()} ${method} ${url}` +
`${note ? `\n${note}` : ''}\n` +
`curl -X ${method} '${url}' -H 'Authorization: Bearer <session token>' ` +
"-H 'Accept: application/pdf' --output out.pdf\n└──",
);
},
/** The outcome of a `rawRequest`, named the same way for a matching pair. */
rawResponse(status: number, url: string, note: string) {
if (!ENABLED) {
return;
}
console.log(`\n┌── API ← ${status} ${url}\n${note}\n└──`);
},
/** Nothing answered at all — no status, no body. */
networkFailure(error: AxiosError) {
if (!ENABLED) {
return;
}
const config = error.config as InternalAxiosRequestConfig | undefined;
console.warn(
`\n┌── API ✗ #${idOf(config)} no response ${label(config)} (${elapsed(config)})\n` +
`${error.code ?? 'no code'}: ${error.message}\n` +
'The request never got an answer. Usual causes: the backend is not running, ' +
'it is bound to 127.0.0.1 rather than 0.0.0.0, this device is on a different ' +
'network from the host, a firewall is closing the port, or cleartext HTTP is ' +
'not permitted for that host in network_security_config.xml.\n└──',
);
},
};
+5 -10
View File
@@ -2,11 +2,12 @@ import {
BaseQueryFn,
createApi,
} from '@reduxjs/toolkit/query/react';
import type { AxiosRequestConfig, AxiosError } from 'axios';
import type { AxiosRequestConfig } from 'axios';
import { QUERY_TAGS } from '../../constants/queryTags';
import type { ApiError } from '../../types/api';
import { apiClient } from './apiClient';
import { toApiError } from './serviceHelpers';
export interface AxiosBaseQueryArgs {
url: string;
@@ -30,15 +31,9 @@ const axiosBaseQuery =
return { data: response.data };
} catch (error) {
const axiosError = error as AxiosError;
return {
error: {
status: Number(axiosError.response?.status ?? 500),
message: axiosError.message || 'Something went wrong',
data: axiosError.response?.data,
},
};
// Keeps the backend's `errorCode` and validation field map intact, so
// components can branch on the code rather than parse a message.
return { error: toApiError(error) };
}
};
+167
View File
@@ -0,0 +1,167 @@
import {
CBS_ERROR_CODES,
CbsApiError,
describeError,
isCbsApiError,
isCbsErrorCode,
isClientErrorCode,
isRetryableError,
} from './cbsErrors';
describe('error code catalogue', () => {
it('covers all 45 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(45);
expect(new Set(CBS_ERROR_CODES).size).toBe(45);
});
it('knows the cheque book codes', () => {
expect(isCbsErrorCode('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(true);
expect(isCbsErrorCode('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(true);
});
it('does not invite a retry on either cheque book refusal', () => {
// Neither is transient. The account cannot take a cheque book at all, or
// the bank understood the request and refused it — an identical retry gets
// an identical answer, and on a paid product that is worse than useless.
expect(isRetryableError('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(false);
expect(isRetryableError('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(false);
});
it('knows the statement codes', () => {
expect(isCbsErrorCode('INVALID_DATE_RANGE')).toBe(true);
expect(isCbsErrorCode('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(true);
});
it('does not invite a retry on either statement refusal', () => {
// Both are the request's fault, not the bank's: one range is unusable and
// the other view does not exist. Repeating the identical call gets the
// identical answer, so the screen must offer a different range — or
// nothing — rather than a retry.
expect(isRetryableError('INVALID_DATE_RANGE')).toBe(false);
expect(isRetryableError('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(false);
});
it('knows the payee-validate codes, which come from IMPS and not from CBS', () => {
expect(isCbsErrorCode('IMPS_UNAVAILABLE')).toBe(true);
expect(isCbsErrorCode('IMPS_INVALID_RESPONSE')).toBe(true);
expect(isCbsErrorCode('IMPS_CONFIGURATION_ERROR')).toBe(true);
});
it('invites a retry when the switch was unreachable, but not when it is misconfigured', () => {
// Validate saves nothing, so a failed call is safe to repeat — unlike the
// Positive Pay write below. A configuration fault is not the user's to fix.
expect(isRetryableError('IMPS_UNAVAILABLE')).toBe(true);
expect(isRetryableError('IMPS_INVALID_RESPONSE')).toBe(true);
expect(isRetryableError('IMPS_CONFIGURATION_ERROR')).toBe(false);
});
it('knows the Positive Pay codes', () => {
expect(isCbsErrorCode('POSITIVE_PAY_NOT_AVAILABLE')).toBe(true);
expect(isCbsErrorCode('CHEQUE_ALREADY_REGISTERED')).toBe(true);
expect(isCbsErrorCode('POSITIVE_PAY_DECLINED')).toBe(true);
});
it('knows the stop-payment codes', () => {
expect(isCbsErrorCode('CHEQUE_NOT_FOUND')).toBe(true);
expect(isCbsErrorCode('STOP_PAYMENT_NOT_AVAILABLE')).toBe(true);
expect(isCbsErrorCode('CHEQUE_ALREADY_STOPPED')).toBe(true);
expect(isCbsErrorCode('STOP_PAYMENT_DECLINED')).toBe(true);
});
it('does not invite a retry on a stop-payment refusal either', () => {
// Same reasoning as Positive Pay: stopping a cheque is a write. A second
// attempt after a refusal changes nothing, and after a success it returns
// CHEQUE_ALREADY_STOPPED — which reads like the customer's mistake.
expect(isRetryableError('CHEQUE_NOT_FOUND')).toBe(false);
expect(isRetryableError('CHEQUE_ALREADY_STOPPED')).toBe(false);
expect(isRetryableError('STOP_PAYMENT_DECLINED')).toBe(false);
expect(isRetryableError('STOP_PAYMENT_NOT_AVAILABLE')).toBe(false);
});
it('does not invite a retry on a Positive Pay refusal', () => {
// Registering a cheque is a write. A refusal is final, and offering "try
// again" would only produce CHEQUE_ALREADY_REGISTERED or a duplicate.
expect(isRetryableError('CHEQUE_ALREADY_REGISTERED')).toBe(false);
expect(isRetryableError('POSITIVE_PAY_DECLINED')).toBe(false);
expect(isRetryableError('POSITIVE_PAY_NOT_AVAILABLE')).toBe(false);
});
it('has copy for every code, so no failure renders as a blank toast', () => {
for (const code of CBS_ERROR_CODES) {
const copy = describeError(code);
expect(copy.title.length).toBeGreaterThan(0);
expect(copy.description.length).toBeGreaterThan(0);
}
});
it('recognises documented codes and rejects invented ones', () => {
expect(isCbsErrorCode('MPIN_LOCKED')).toBe(true);
expect(isCbsErrorCode('MPIN_UNLOCKED')).toBe(false);
expect(isCbsErrorCode(undefined)).toBe(false);
});
it('falls back to generic copy for an unknown or missing code', () => {
expect(describeError('SOMETHING_NEW').title).toBe(describeError(undefined).title);
});
});
describe('isRetryableError', () => {
// Retrying these has a real chance of working: the bank was unreachable or a
// breaker was open.
it('marks upstream and transport failures retryable', () => {
expect(isRetryableError('CBS_UNAVAILABLE')).toBe(true);
expect(isRetryableError('CONNECTOR_UNAVAILABLE')).toBe(true);
expect(isRetryableError('NETWORK_ERROR')).toBe(true);
});
// Retrying these can only waste an attempt, and against MPIN_LOCKED there is
// nothing to retry at all — the customer must visit a branch.
it('does not mark user-input or policy failures retryable', () => {
expect(isRetryableError('OTP_INVALID')).toBe(false);
expect(isRetryableError('INVALID_CREDENTIALS')).toBe(false);
expect(isRetryableError('MPIN_LOCKED')).toBe(false);
expect(isRetryableError('VALIDATION_ERROR')).toBe(false);
});
});
describe('CbsApiError', () => {
it('carries the code and the validation field map', () => {
const error = new CbsApiError({
status: 400,
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
fieldErrors: { mobileNumber: 'must be exactly 10 digits' },
});
expect(isCbsApiError(error)).toBe(true);
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.fieldErrors?.mobileNumber).toBe('must be exactly 10 digits');
expect(error.describe().title).toBe(describeError('VALIDATION_ERROR').title);
});
it('is a real Error, so it survives being thrown and caught', () => {
expect(() => {
throw new CbsApiError({ status: 0, code: 'NETWORK_ERROR', message: 'offline' });
}).toThrow('offline');
});
it('does not mistake a plain object for one', () => {
expect(isCbsApiError({ status: 400, code: 'OTP_INVALID', message: 'nope' })).toBe(false);
});
});
describe('isClientErrorCode', () => {
// These carry a transport detail, not a message anyone wrote for a customer.
it('is true for the codes the client synthesises', () => {
expect(isClientErrorCode('NETWORK_ERROR')).toBe(true);
expect(isClientErrorCode('REQUEST_TIMEOUT')).toBe(true);
expect(isClientErrorCode('UNKNOWN_ERROR')).toBe(true);
});
it('is false for codes the backend names, whose message is worth showing', () => {
expect(isClientErrorCode('OTP_INVALID')).toBe(false);
expect(isClientErrorCode('VALIDATION_ERROR')).toBe(false);
expect(isClientErrorCode(undefined)).toBe(false);
});
});
+358
View File
@@ -0,0 +1,358 @@
/**
* The error codes the CBS backend can return, plus the copy the app shows
* for each. `errorCode` is the stable contract — `message` is free to be
* reworded or localised server-side — so every branch in the app switches on
* the code and never on the message or the HTTP status.
*/
export const CBS_ERROR_CODES = [
'VALIDATION_ERROR',
'MALFORMED_REQUEST',
'OTP_INVALID',
'OTP_EXPIRED',
'BENEFICIARY_DETAILS_INCOMPLETE',
'INVALID_CREDENTIALS',
'SESSION_INVALID',
'DEVICE_NOT_BOUND',
'RESOURCE_NOT_FOUND',
'CUSTOMER_NOT_FOUND',
'NO_ACCOUNTS_FOUND',
'ACCOUNT_NOT_FOUND',
'CHEQUE_NOT_FOUND',
'STOP_PAYMENT_NOT_AVAILABLE',
'INVALID_DATE_RANGE',
'STATEMENT_VIEW_NOT_AVAILABLE',
'ORG_NOT_CONFIGURED',
'BENEFICIARY_NOT_FOUND',
'METHOD_NOT_ALLOWED',
'DUPLICATE_RECORD',
'ALREADY_REGISTERED',
'OTP_NOT_VERIFIED',
'MPIN_ALREADY_SET',
'MPIN_NOT_SET',
'BENEFICIARY_ALREADY_ADDED',
'POSITIVE_PAY_NOT_AVAILABLE',
'CHEQUE_ALREADY_REGISTERED',
'CHEQUE_ALREADY_STOPPED',
'POSITIVE_PAY_DECLINED',
'STOP_PAYMENT_DECLINED',
'CHEQUE_BOOK_NOT_AVAILABLE',
'CHEQUE_BOOK_REQUEST_DECLINED',
'MPIN_LOCKED',
'OTP_ATTEMPTS_EXCEEDED',
'INTERNAL_ERROR',
'CBS_CONNECTOR_NOT_SUPPORTED',
'MMID_ALLOCATION_FAILED',
'CBS_UNAVAILABLE',
'CBS_ERROR',
'IMPS_UNAVAILABLE',
'IMPS_INVALID_RESPONSE',
'IMPS_CONFIGURATION_ERROR',
'SMS_DELIVERY_FAILED',
'OTP_PROVIDER_UNAVAILABLE',
'CONNECTOR_UNAVAILABLE',
] as const;
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
/** Codes the client synthesises when the request never reached the backend. */
export type ClientErrorCode = 'NETWORK_ERROR' | 'REQUEST_TIMEOUT' | 'UNKNOWN_ERROR';
export type AnyErrorCode = CbsErrorCode | ClientErrorCode;
const CBS_ERROR_CODE_SET = new Set<string>(CBS_ERROR_CODES);
export const isCbsErrorCode = (value: unknown): value is CbsErrorCode =>
typeof value === 'string' && CBS_ERROR_CODE_SET.has(value);
const CLIENT_ERROR_CODES = new Set<string>([
'NETWORK_ERROR',
'REQUEST_TIMEOUT',
'UNKNOWN_ERROR',
]);
/**
* True for a failure the client invented because nothing answered. The
* attached message is then a transport detail — axios says "Network Error" —
* which tells a customer nothing, so the UI should use our own copy instead of
* preferring it the way it does for a message the backend actually wrote.
*/
export const isClientErrorCode = (code: string | undefined) =>
code !== undefined && CLIENT_ERROR_CODES.has(code);
/** Field name to message, as `VALIDATION_ERROR` returns it. Paints onto a form. */
export type FieldErrors = Record<string, string>;
interface ErrorCopy {
title: string;
description: string;
/**
* True when retrying the identical request has a real chance of succeeding —
* the bank being unreachable, a circuit breaker open, a transient 500.
*/
retryable?: boolean;
}
const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
VALIDATION_ERROR: {
title: 'Check the highlighted fields',
description: 'Some details need correcting before we can continue.',
},
MALFORMED_REQUEST: {
title: 'We could not read that request',
description: 'Please try once more.',
retryable: true,
},
OTP_INVALID: {
title: 'That code is incorrect',
description: 'Re-enter the code we sent to your mobile.',
},
OTP_EXPIRED: {
title: 'That code has expired',
description: 'Request a fresh code and try again.',
},
BENEFICIARY_DETAILS_INCOMPLETE: {
title: 'Payee details are incomplete',
description: 'Fill in the fields required for the payee type you picked.',
},
INVALID_CREDENTIALS: {
title: 'Sign-in failed',
description: 'Check your mobile number and MPIN, then try again.',
},
SESSION_INVALID: {
title: 'Session ended',
description: 'Please sign in again to continue securely.',
},
DEVICE_NOT_BOUND: {
title: 'This device is not registered',
description: 'Register before signing in.',
},
RESOURCE_NOT_FOUND: {
title: 'Not found',
description: 'We could not find what you asked for.',
},
CUSTOMER_NOT_FOUND: {
title: 'We could not find that customer',
description: 'Check the mobile number registered with your bank.',
},
NO_ACCOUNTS_FOUND: {
title: 'No accounts to link',
description: 'Your bank has no accounts we can link to this number.',
},
ACCOUNT_NOT_FOUND: {
title: 'Account unavailable',
description: 'That account is no longer part of your profile.',
},
/**
* Nothing on file for this account and cheque number. The bank keeps no
* separate record of issued cheques, so this also covers the common case of
* a perfectly real cheque that was simply never declared under Positive Pay
* — which is why the copy says what to do rather than just "not found".
*/
CHEQUE_NOT_FOUND: {
title: 'We could not find that cheque',
description:
'Check the account and the six-digit number. Only a cheque registered under Positive Pay can be found here.',
},
STOP_PAYMENT_NOT_AVAILABLE: {
title: 'Stop payment is not available',
description: 'Your bank does not offer it on this account. Contact your branch instead.',
},
INVALID_DATE_RANGE: {
title: 'That date range will not work',
description: 'Pick a start and an end date, with the start on or before the end.',
},
STATEMENT_VIEW_NOT_AVAILABLE: {
title: 'Statements are not available here',
description: 'Your bank does not expose a ledger for this account.',
},
ORG_NOT_CONFIGURED: {
title: 'Organisation unavailable',
description: 'This organisation is unknown or has been disabled.',
},
BENEFICIARY_NOT_FOUND: {
title: 'Payee not found',
description: 'Check the account number and IFSC, or the mobile number and MMID.',
},
METHOD_NOT_ALLOWED: {
title: 'Something went wrong',
description: 'The app made a request the server does not accept.',
},
DUPLICATE_RECORD: {
title: 'Already saved',
description: 'That record was just created. Refresh to see it.',
},
ALREADY_REGISTERED: {
title: 'You are already registered',
description: 'Sign in with your MPIN instead.',
},
OTP_NOT_VERIFIED: {
title: 'Verification expired',
description: 'Your setup window has closed. Start registration again.',
},
MPIN_ALREADY_SET: {
title: 'MPIN already set',
description: 'Sign in with the MPIN you created.',
},
MPIN_NOT_SET: {
title: 'Registration unfinished',
description: 'Finish setting your MPIN to start banking.',
},
BENEFICIARY_ALREADY_ADDED: {
title: 'Payee already saved',
description: 'This payee is already in your list.',
},
POSITIVE_PAY_NOT_AVAILABLE: {
title: 'Positive Pay is not available',
description: 'Your bank does not offer it on this account.',
},
CHEQUE_ALREADY_REGISTERED: {
title: 'This cheque is already registered',
description:
'Nothing more to do — the bank already holds these details. Check the number if you meant a different cheque.',
},
/**
* A retried stop, where the first call already took effect. Nothing went
* wrong and nothing changed — so the copy reassures rather than alarms.
*/
CHEQUE_ALREADY_STOPPED: {
title: 'This cheque is already stopped',
description:
'Your earlier instruction went through. The bank will dishonour this cheque if it is presented.',
},
POSITIVE_PAY_DECLINED: {
title: 'Your bank declined the registration',
description: 'Contact your branch to register this cheque.',
},
STOP_PAYMENT_DECLINED: {
title: 'Your bank declined the stop instruction',
description: 'The cheque has not been stopped. Contact your branch to take this further.',
},
/**
* Answers the charges quote as well as the issue itself, so this copy has to
* make sense before the customer has committed to anything — hence no
* "nothing was charged" reassurance, which would be confusing on a quote.
*/
CHEQUE_BOOK_NOT_AVAILABLE: {
title: 'Cheque books are not available on this account',
description: 'Your bank does not issue them on this account type. Try another account.',
},
CHEQUE_BOOK_REQUEST_DECLINED: {
title: 'Your bank declined the request',
description: 'No cheque book was issued and nothing was charged. Contact your branch.',
},
MPIN_LOCKED: {
title: 'MPIN locked',
description: 'Too many wrong attempts. Visit your branch to unlock it.',
},
OTP_ATTEMPTS_EXCEEDED: {
title: 'Too many wrong codes',
description: 'Start registration again to get a new code.',
},
INTERNAL_ERROR: {
title: 'Something went wrong at the bank',
description: 'Please try again in a moment.',
retryable: true,
},
CBS_CONNECTOR_NOT_SUPPORTED: {
title: 'Service unavailable',
description: 'This is a server configuration issue, not a problem with your details.',
},
IMPS_UNAVAILABLE: {
title: 'Could not reach the payments network',
description: 'IMPS did not answer. Nothing was saved — please try again in a moment.',
retryable: true,
},
IMPS_INVALID_RESPONSE: {
title: 'Could not read the payment network reply',
description:
'The response failed its security check, so it cannot be trusted. Try again, and tell your branch if it keeps happening.',
retryable: true,
},
IMPS_CONFIGURATION_ERROR: {
title: 'Payee checks are unavailable',
description: 'This is a server configuration issue, not a problem with the details you entered.',
},
MMID_ALLOCATION_FAILED: {
title: 'We could not finish setting up your accounts',
description: 'Please try again shortly.',
retryable: true,
},
CBS_UNAVAILABLE: {
title: 'Your bank is unreachable',
description: 'Please try again in a moment.',
retryable: true,
},
CBS_ERROR: {
title: 'Your bank returned an error',
description: 'Please try again in a moment.',
retryable: true,
},
SMS_DELIVERY_FAILED: {
title: 'We could not send the code',
description: 'Please request it again.',
retryable: true,
},
OTP_PROVIDER_UNAVAILABLE: {
title: 'Verification service is down',
description: 'Please try again in a few minutes.',
retryable: true,
},
CONNECTOR_UNAVAILABLE: {
title: 'Service temporarily unavailable',
description: 'We are backing off from your bank. Try again shortly.',
retryable: true,
},
NETWORK_ERROR: {
title: 'No connection',
description: 'Check your network and try again.',
retryable: true,
},
REQUEST_TIMEOUT: {
title: 'The request timed out',
description: 'Your bank took too long to answer. Try again.',
retryable: true,
},
UNKNOWN_ERROR: {
title: 'Something went wrong',
description: 'Please try again.',
retryable: true,
},
};
export const describeError = (code: string | undefined): ErrorCopy =>
(code ? ERROR_COPY[code as AnyErrorCode] : undefined) ?? ERROR_COPY.UNKNOWN_ERROR;
export const isRetryableError = (code: string | undefined) =>
Boolean(describeError(code).retryable);
/**
* An error the CBS backend named, or one the client synthesised when the call
* never got an answer. Carries the code the UI switches on and, for
* `VALIDATION_ERROR`, the field map that paints straight onto the form.
*/
export class CbsApiError extends Error {
readonly status: number;
readonly code: AnyErrorCode;
readonly fieldErrors?: FieldErrors;
constructor(params: {
status: number;
code: AnyErrorCode;
message: string;
fieldErrors?: FieldErrors;
}) {
super(params.message);
this.name = 'CbsApiError';
this.status = params.status;
this.code = params.code;
this.fieldErrors = params.fieldErrors;
}
/** Copy suitable for a toast or an inline banner. */
describe() {
return describeError(this.code);
}
}
export const isCbsApiError = (value: unknown): value is CbsApiError =>
value instanceof CbsApiError;
+102 -2
View File
@@ -1,8 +1,63 @@
export const endpoints = {
/** Unauthenticated by necessity — between them these produce a session. */
registration: {
start: '/registration',
verifyOtp: '/registration/verify-otp',
mpin: '/registration/mpin',
},
crypto: {
publicKey: '/crypto/public-key',
},
/**
* Server-driven feature switches. Deliberately callable without a session —
* the sign-in screen's own controls are gated, so this has to resolve before
* anyone has signed in.
*/
features: {
config: '/features/config',
},
auth: {
login: '/auth/login',
verifyOtp: '/auth/verify-otp',
setupMpin: '/auth/setup-mpin',
me: '/auth/me',
logout: '/auth/logout',
},
accounts: {
/**
* Every account on the signed-in profile. No parameters — the profile comes
* entirely from the session. This is the only endpoint that returns a full
* account number rather than a masked one.
*/
list: '/accounts',
mmid: '/accounts/mmid',
/**
* A page of ledger entries for one account. Takes the full account number
* from `list` above, and an optional `fromDate`/`toDate` pair that the
* bank rejects unless both are given or neither is.
*/
statement: '/accounts/statement',
/**
* The same range `statement` pages through, as one PDF. Takes the identical
* `accountNumber` and optional `fromDate`/`toDate` pair and no
* `page`/`size` — the whole range becomes one document. Answers
* `application/pdf`, not the JSON envelope every other endpoint returns.
*/
statementExport: '/accounts/statement/export',
/**
* The last 10 entries as a short PDF, led by the current available balance.
* `accountNumber` only — no date range, and no envelope.
*/
miniStatement: '/accounts/mini-statement',
/**
* One deposit's terms, by `accountNumber` query parameter. Takes the full
* number from `list` above — the masked form every other endpoint returns
* will not resolve.
*/
depositDetail: '/accounts/deposits/detail',
},
beneficiaries: {
list: '/beneficiaries',
save: '/beneficiaries',
validate: '/beneficiaries/validate',
},
dashboard: {
summary: '/dashboard/summary',
@@ -33,6 +88,51 @@ export const endpoints = {
requests: '/services/requests',
chat: '/services/chat',
},
/**
* Declares a cheque before it is presented. Write-only: the bank is the
* system of record and keeps no client-side copy, so there is nothing to
* list or fetch back — `referenceId` in the response is the only handle.
*/
positivePay: {
register: '/positive-pay',
/**
* Checks the bank does *not* already hold this cheque, before the customer
* fills in the full registration form. "Not found" is the happy path here
* and answers 200 with a null `data`; a cheque already on file is refused
* with `CHEQUE_ALREADY_REGISTERED`.
*/
verify: '/positive-pay/verify',
},
/**
* Stopping a cheque already declared under Positive Pay.
*
* `verify` resolves to the same core-banking lookup as
* `positivePay.verify` — what differs is which answer is good news. There,
* finding the cheque is a rejection; here it is the whole point, and the
* cheque's own detail comes back so the customer can confirm what they are
* about to stop. A cheque that never went through `positivePay.register`
* does not exist to either of these.
*/
cheque: {
verify: '/check/verify',
stopPayment: '/check/stop-payment',
},
/**
* Cheque book issuance — the one cheque journey the bank keeps a history
* for, unlike Positive Pay and stop payment above.
*
* `charges` quotes the fee before the customer commits to `issue`, the same
* preview-then-act convention as `positivePay.verify`. `issue` is
* synchronous: the serial range comes back in its own response, so there is
* no pending state and nothing to poll. `issue` and `list` are the same
* path under different methods, named apart because the screens reach for
* one or the other and a shared constant reads like a copy-paste slip.
*/
chequeBook: {
charges: '/cheque-book/charges',
issue: '/cheque-book',
list: '/cheque-book',
},
loans: {
products: '/loans/products',
active: '/loans/active',
+150 -7
View File
@@ -1,6 +1,10 @@
import type { AxiosError, AxiosInstance } from 'axios';
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import type { CbsEnvelope } from '../../types/cbs';
import { sessionManager } from '../security/sessionManager';
import { secureStorage } from '../storage/secureStorage';
import { apiLogger } from './apiLogger';
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
const unauthorizedHandlers = new Set<() => void>();
@@ -12,6 +16,63 @@ export const registerUnauthorizedHandler = (handler: () => void) => {
};
};
const looksLikeEnvelope = (value: unknown): value is CbsEnvelope<unknown> =>
typeof value === 'object' && value !== null && 'success' in value;
/**
* `VALIDATION_ERROR` is the one failure that also fills `data`, with a
* field-to-message map. Anything else in there on a failure is not a field map.
*/
const readFieldErrors = (envelope: CbsEnvelope<unknown>): FieldErrors | undefined => {
if (envelope.errorCode !== 'VALIDATION_ERROR') {
return undefined;
}
const { data } = envelope;
if (typeof data !== 'object' || data === null) {
return undefined;
}
const entries = Object.entries(data as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};
/** The absolute URL a failed request was aimed at, for a dev-only diagnostic. */
const describeTarget = (error: AxiosError) => {
const base = error.config?.baseURL ?? '';
const path = error.config?.url ?? '';
return `${base}${path}` || 'the server';
};
const envelopeToError = (status: number, envelope: CbsEnvelope<unknown>) =>
new CbsApiError({
status,
code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR',
message: envelope.message || 'Unexpected service error',
fieldErrors: readFieldErrors(envelope),
});
/**
* `SESSION_INVALID` means the token we hold is dead and every screen must drop
* it. `INVALID_CREDENTIALS` shares the 401 but means a wrong MPIN on the
* sign-in form — bouncing the user out of a screen they are not in yet would
* be nonsense, so only the former trips the global handler.
*/
const handleSessionLoss = async (error: CbsApiError) => {
if (error.code !== 'SESSION_INVALID') {
return;
}
await secureStorage.clearSession();
sessionManager.clear();
unauthorizedHandlers.forEach((handler) => handler());
};
export const attachInterceptors = (client: AxiosInstance) => {
client.interceptors.request.use(async (config) => {
const token = await secureStorage.getAccessToken();
@@ -22,18 +83,100 @@ export const attachInterceptors = (client: AxiosInstance) => {
config.headers = nextHeaders as never;
}
// Logged here, at the end of the interceptor, so the curl carries the
// Authorization header the request will actually be sent with.
apiLogger.request(config);
return config;
});
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
await secureStorage.clearSession();
unauthorizedHandlers.forEach((handler) => handler());
async (response: AxiosResponse) => {
apiLogger.response(response);
if (!looksLikeEnvelope(response.data)) {
return response;
}
return Promise.reject(error);
const envelope = response.data as CbsEnvelope<unknown>;
// Branch on `success`, not on the HTTP status: a failure can arrive with
// a 2xx, and treating it as a payload would hand callers `undefined`.
if (!envelope.success) {
const error = envelopeToError(response.status, envelope);
apiLogger.failure(response, `envelope said success: false -> ${error.code}`);
await handleSessionLoss(error);
throw error;
}
// Any authenticated call refreshes the server's idle timer, so mirror
// that locally — otherwise the app would sign the user out while the
// session is still very much alive.
if (response.config.headers?.Authorization) {
sessionManager.touch();
}
// Unwrap so callers read the payload directly rather than the envelope.
return { ...response, data: envelope.data };
},
async (error: AxiosError) => {
if (error instanceof CbsApiError) {
return Promise.reject(error);
}
if (error.response) {
apiLogger.failure(error.response, `HTTP error ${error.code ?? ''}`.trim());
} else {
apiLogger.networkFailure(error);
}
const status = Number(error.response?.status ?? 0);
const body = error.response?.data;
if (looksLikeEnvelope(body)) {
const cbsError = envelopeToError(status, body);
await handleSessionLoss(cbsError);
return Promise.reject(cbsError);
}
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
return Promise.reject(
new CbsApiError({
status: 0,
code: 'REQUEST_TIMEOUT',
message: 'The request timed out',
}),
);
}
// No response at all: nothing answered on the other end. The usual causes
// are the backend not running, it being bound to 127.0.0.1 instead of
// 0.0.0.0, the handset being on a different network from the host, a
// firewall on the host port, or cleartext HTTP blocked in a release build.
//
// Naming the URL that failed matters more than it looks: without it this
// is indistinguishable from a server-side fault, and the address the
// phone needs is often not the one that works in the laptop's browser.
if (!error.response) {
return Promise.reject(
new CbsApiError({
status: 0,
code: 'NETWORK_ERROR',
message: __DEV__
? `Could not reach ${describeTarget(error)}. Check the backend is running and that this device can reach that address.`
: error.message || 'Could not reach the server',
}),
);
}
return Promise.reject(
new CbsApiError({
status,
code: 'UNKNOWN_ERROR',
message: error.message || 'Unexpected service error',
}),
);
},
);
};
+30
View File
@@ -3,6 +3,7 @@ import type { AxiosError } from 'axios';
import { appConfig } from '../../config/appConfig';
import type { ApiError } from '../../types/api';
import { sleep } from '../../utils/async';
import { isCbsApiError } from './cbsErrors';
const isApiError = (value: unknown): value is ApiError =>
typeof value === 'object' &&
@@ -11,6 +12,17 @@ const isApiError = (value: unknown): value is ApiError =>
'message' in value;
export const toApiError = (error: unknown): ApiError => {
// A named backend failure. Keep the code and the field map — they are what
// the screens branch on and paint onto forms.
if (isCbsApiError(error)) {
return {
status: error.status,
message: error.message,
code: error.code,
fieldErrors: error.fieldErrors,
};
}
if (isApiError(error)) {
return error;
}
@@ -20,10 +32,17 @@ export const toApiError = (error: unknown): ApiError => {
return {
status: Number(axiosError.response?.status ?? 500),
message: axiosError.message || 'Unexpected service error',
code: 'UNKNOWN_ERROR',
data: axiosError.response?.data,
};
};
/**
* Fixture-backed resolution for the modules that have no backend behind them
* yet. Endpoints the CBS API actually implements must not go through here —
* they call `apiClient` directly so a failure surfaces instead of being
* papered over with demo data.
*/
export const resolveWithMock = async <T>({
mockData,
request,
@@ -50,3 +69,14 @@ export const resolveWithMock = async <T>({
};
}
};
/** Same contract as `resolveWithMock`, but always live. */
export const resolveLive = async <T>(
request: () => Promise<T>,
): Promise<{ data: T } | { error: ApiError }> => {
try {
return { data: await request() };
} catch (error) {
return { error: toApiError(error) };
}
};
+243
View File
@@ -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() };
},
};
+216
View File
@@ -0,0 +1,216 @@
import { Platform } from 'react-native';
import ReactNativeBlobUtil from 'react-native-blob-util';
import { env } from '../../config/env';
import { apiLogger } from '../api/apiLogger';
import { CbsApiError, isCbsErrorCode } from '../api/cbsErrors';
import { sessionManager } from '../security/sessionManager';
import { secureStorage } from '../storage/secureStorage';
import type { CbsPdfDocument } from '../../types/cbs';
/**
* The two statement endpoints that answer a PDF rather than the JSON envelope.
*
* Everything else in the app goes through `apiClient`, whose response
* interceptor unwraps that envelope — which is exactly why these cannot: axios
* on React Native has no usable binary response, and the interceptor would try
* to read a PDF as one of ours. So this module speaks to the backend directly
* and reimplements the three things the interceptor would otherwise have done:
* attach the bearer token, touch the idle timer on success, and turn a
* `success: false` body into a `CbsApiError` the screens already know how to
* render.
*
* The file lands in the app's own cache directory. That needs no runtime
* permission on either platform, is not visible to other apps, and goes away
* with the app — which is the right home for a document the customer is about
* to view or share, and the wrong one for anything they expect to keep, hence
* `shareDocument` below rather than a silent write to Downloads.
*/
/** `CONTENT-DISPOSITION` casing varies by server; match on the lowercased key. */
const readFileName = (headers: Record<string, string>) => {
const entry = Object.entries(headers).find(
([key]) => key.toLowerCase() === 'content-disposition',
);
if (!entry) {
return null;
}
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(entry[1]);
return match?.[1] ? decodeURIComponent(match[1]) : null;
};
const readContentType = (headers: Record<string, string>) => {
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === 'content-type');
return entry?.[1]?.toLowerCase() ?? '';
};
/**
* A failure body that arrived where a PDF was expected.
*
* The bank answers its ordinary envelope for every refusal on these endpoints
* — `ACCOUNT_NOT_FOUND`, `INVALID_DATE_RANGE`, a dead session — so the body
* has to be read as JSON before it can be trusted as a document. A response
* that is neither a PDF nor a readable envelope is reported as
* `UNKNOWN_ERROR` rather than handed on as a file that will not open.
*/
const toApiFailure = async (
status: number,
response: Awaited<ReturnType<ReturnType<typeof ReactNativeBlobUtil.config>['fetch']>>,
) => {
try {
const envelope = JSON.parse(await response.text()) as {
message?: string;
errorCode?: string;
};
return new CbsApiError({
status,
code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR',
message: envelope.message || 'The document could not be prepared',
});
} catch {
return new CbsApiError({
status,
code: status === 401 ? 'SESSION_INVALID' : 'UNKNOWN_ERROR',
message: 'The document could not be prepared',
});
}
};
const toQueryString = (params: Record<string, string | undefined>) => {
const pairs = Object.entries(params).filter(
(entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== '',
);
return pairs
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
};
/**
* Fetches an authenticated PDF straight to a file on disk.
*
* Streamed to the file rather than buffered in JS: a statement for a wide date
* range is the one response in this app that can run to megabytes, and holding
* it as a base64 string would triple that in memory for no gain.
*/
export const downloadPdf = async ({
path,
params,
fallbackFileName,
}: {
/** Endpoint path, relative to the API prefix. */
path: string;
params: Record<string, string | undefined>;
/** Used when the server sends no `Content-Disposition`. */
fallbackFileName: string;
}): Promise<CbsPdfDocument> => {
const token = await secureStorage.getAccessToken();
if (!token) {
// Nothing to send. Reported as the same code the interceptor raises for a
// token the server has rejected, so the global 401 handling applies here
// too rather than this one call failing in its own private way.
throw new CbsApiError({
status: 401,
code: 'SESSION_INVALID',
message: 'Your session has ended',
});
}
const query = toQueryString(params);
const url = `${env.apiBaseUrl}${path}${query ? `?${query}` : ''}`;
apiLogger.rawRequest('GET', url, 'Binary download — outside apiClient.');
const response = await ReactNativeBlobUtil.config({
fileCache: true,
appendExt: 'pdf',
}).fetch('GET', url, {
Authorization: `Bearer ${token}`,
Accept: 'application/pdf',
});
const status = response.info().status;
const headers = (response.info().headers ?? {}) as Record<string, string>;
const contentType = readContentType(headers);
// Branch on the payload, not on the status: this backend can answer a
// failure envelope with a 200, exactly as it does everywhere else.
if (status >= 400 || !contentType.includes('application/pdf')) {
const failure = await toApiFailure(status, response);
apiLogger.rawResponse(
status,
url,
`${failure.code}: ${failure.message} (content-type: ${contentType || 'none'})`,
);
// The cached file is a JSON error body, not a document. Drop it here
// rather than leaving it to be "opened" as a PDF later.
await response.flush();
if (failure.code === 'SESSION_INVALID') {
await secureStorage.clearSession();
sessionManager.clear();
}
throw failure;
}
// A successful authenticated call refreshes the server's idle window, and
// this one bypassed the interceptor that normally mirrors that locally.
sessionManager.touch();
const document = {
path: response.path(),
fileName: readFileName(headers) ?? fallbackFileName,
};
apiLogger.rawResponse(status, url, `${document.fileName} → ${document.path}`);
return document;
};
/**
* Hands the document to the OS so the customer can read it, print it, or save
* it wherever they actually keep things.
*
* Deliberately not a write into Downloads or the Files app. The app's own
* cache needs no storage permission and no scoped-storage dance, and the share
* sheet is where a customer chooses what happens to their statement — mail it,
* keep it, send it to an accountant — rather than the app deciding for them.
*/
export const shareDocument = async (document: CbsPdfDocument) => {
if (Platform.OS === 'ios') {
await ReactNativeBlobUtil.ios.presentOptionsMenu(document.path);
return;
}
// `actionViewIntent` needs a content:// URI, which the library's FileProvider
// produces from a path inside the app's own directories — the cache dir
// `downloadPdf` writes to qualifies.
await ReactNativeBlobUtil.android.actionViewIntent(document.path, 'application/pdf');
};
/**
* Opens the document for reading, without the share sheet in the way.
*
* On Android this is the same intent as sharing — the OS picks a viewer — so
* the two differ only on iOS, where a preview is a document controller and a
* share is an activity sheet.
*/
export const previewDocument = async (document: CbsPdfDocument) => {
if (Platform.OS === 'ios') {
await ReactNativeBlobUtil.ios.openDocument(document.path);
return;
}
await ReactNativeBlobUtil.android.actionViewIntent(document.path, 'application/pdf');
};
+80
View File
@@ -0,0 +1,80 @@
import 'react-native-get-random-values';
import { v4 as uuidv4 } from 'uuid';
import * as Keychain from 'react-native-keychain';
const DEVICE_SERVICE = 'com.bankcbs.mobile.deviceId';
/**
* Kept in memory as well so the many calls that need it during one flow do not
* each hit the Keystore.
*/
let cachedDeviceId: string | null = null;
let inflight: Promise<string> | null = null;
const persist = async (deviceId: string) => {
await Keychain.setGenericPassword('deviceId', deviceId, {
service: DEVICE_SERVICE,
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
};
const load = async (): Promise<string> => {
const stored = await Keychain.getGenericPassword({ service: DEVICE_SERVICE });
if (stored && stored.password) {
return stored.password;
}
const created = uuidv4();
await persist(created);
return created;
};
/**
* The profile the customer registers is bound to this value. Registration and
* every later sign-in must send the *same* one — a different value comes back
* as `DEVICE_NOT_BOUND`, which reads like a server fault but is a client bug.
* So it is generated exactly once, on first launch, and never regenerated
* except by an explicit `reset()`.
*
* `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` rather than `WHEN_UNLOCKED_*`: the id
* is not a secret, and sign-in must read it before the user has authenticated.
*/
export const deviceIdentity = {
async get(): Promise<string> {
if (cachedDeviceId) {
return cachedDeviceId;
}
// Concurrent callers on a cold start would otherwise each mint a UUID and
// the last write would win, silently unbinding the device.
inflight = inflight ?? load();
try {
cachedDeviceId = await inflight;
return cachedDeviceId;
} finally {
inflight = null;
}
},
/** In-memory peek for code that cannot await. Null before the first `get()`. */
peek() {
return cachedDeviceId;
},
/**
* Mints a fresh id, unbinding this install from its registered profile. Only
* for a deliberate "Register again" action — the customer will
* have to go through registration once more.
*/
async reset(): Promise<string> {
const next = uuidv4();
await persist(next);
cachedDeviceId = next;
return next;
},
};
+90
View File
@@ -0,0 +1,90 @@
import 'react-native-get-random-values';
import forge from 'node-forge';
import { v4 as uuidv4 } from 'uuid';
import { apiClient } from '../api/apiClient';
import { endpoints } from '../api/endpoints';
import type { PublicKeyResult } from '../../types/cbs';
/**
* The server's key is configured rather than generated at startup, so it
* survives restarts and can be pinned. It is only ever used as a fallback for
* when the key fetch itself fails — a developer running their own backend gets
* the fetched key, which is the safer default.
*/
export const PINNED_PUBLIC_KEY =
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxhaxPpoewSn+NCHJuICPVmMCO9f3VXN8zMUFryUv5kcn9rCwQMF023o+xtQCljyJdDaJE805lkp9FMZRupSro9auwmGGHTP5fgX5arn5p21LjbcITBxVIh+Uvj1tNKdxhd69xbTf98LfWGK9DspbWd9ZPk6m00La13eMLFR3iBfljzyT9pkENQ5WSvtPrN3+6JS8K9DsCgj/3WbIwwWTPlIN1rXV6gQumFaGl5CVsuibom2tA9pHTjAJi80LLFWP3I7mMkCihvNZT0/eFIBf1WCiOixm4Rt7Xk+Yh05daazMWAJhav+btknQd1gV/1oLolb5uegaobKK5g+xyzA33wIDAQAB';
let cachedKey: forge.pki.rsa.PublicKey | null = null;
const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
const der = forge.util.decode64(base64Der);
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(der));
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
};
const fetchKey = async (): Promise<forge.pki.rsa.PublicKey> => {
try {
const response = await apiClient.get<PublicKeyResult>(endpoints.crypto.publicKey);
const publicKey = response.data?.publicKey;
if (publicKey) {
return importKey(publicKey);
}
} catch {
// Fall through to the pinned key.
}
return importKey(PINNED_PUBLIC_KEY);
};
export const mpinCrypto = {
async getPublicKey(): Promise<forge.pki.rsa.PublicKey> {
cachedKey = cachedKey ?? (await fetchKey());
return cachedKey;
},
/**
* Drop the cached key so the next encryption re-fetches it. Call this after a
* `MALFORMED_REQUEST`, which is what a stale or wrong key looks like from the
* client side — the server cannot say "your key is wrong" without leaking
* whether the profile exists.
*/
invalidate() {
cachedKey = null;
},
/**
* The MPIN never leaves the device in the clear. RSA-OAEP with the server's
* public key, and the ciphertext travels as `encryptedMpin`.
*
* Three things break integrations here, all of them silently:
*
* - MGF1 must be SHA-256, and is passed explicitly. node-forge happens to
* default it to whatever `md` is (verified against 1.4.0, `lib/pkcs1.js`),
* but Java's `OAEPWithSHA-256AndMGF1Padding` notoriously does not — it
* leaves MGF1 on SHA-1. Relying on a library default here produces
* ciphertext the server cannot decrypt, and the error says nothing useful.
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
* and the request is rejected.
* - `nonce` must be fresh on every single call, or it is refused as a replay.
*/
async encryptMpin(mpin: string): Promise<string> {
const publicKey = await this.getPublicKey();
const payload = JSON.stringify({
mpin,
nonce: uuidv4(),
ts: Math.floor(Date.now() / 1000),
});
return forge.util.encode64(
publicKey.encrypt(payload, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
}),
);
},
};
+149 -4
View File
@@ -1,17 +1,162 @@
import { appConfig } from '../../config/appConfig';
let lastActivityAt = Date.now();
/**
* Two independent limits, and the session dies at whichever comes first:
* an idle window the server resets on every authenticated call, and an
* absolute ceiling it never extends however active the user is.
*/
interface SessionWindow {
idleTimeoutMs: number;
/** Epoch ms of the server's absolute expiry, or null when unknown. */
absoluteExpiryAt: number | null;
}
export type SessionExpiryReason = 'idle' | 'absolute';
/**
* When we last exchanged an authenticated request with the server. This — not
* anything the user did — is what the server's idle window is measured from,
* so it is what expiry has to be judged on.
*/
let lastServerContactAt = Date.now();
/**
* When the user last touched the screen. Tracked separately because most of
* this app reads from fixtures: someone can tap around for five minutes without
* a single authenticated call, and treating that as idle would sign them out
* mid-use. Interaction cannot reset the server's timer by itself — only a call
* can — so this drives a keep-alive rather than the expiry clock.
*/
let lastInteractionAt = Date.now();
let window: SessionWindow = {
idleTimeoutMs: appConfig.security.sessionTimeoutMs,
absoluteExpiryAt: null,
};
export const sessionManager = {
/**
* Adopt the limits the server reported at sign-in. `idleTimeoutSeconds` and
* `expiresAt` come straight off the login response, so the app never has to
* guess and never drifts from what the backend will enforce.
*/
configure({
idleTimeoutSeconds,
expiresAt,
}: {
idleTimeoutSeconds?: number;
expiresAt?: string;
}) {
const absolute = expiresAt ? Date.parse(expiresAt) : NaN;
window = {
idleTimeoutMs:
typeof idleTimeoutSeconds === 'number' && idleTimeoutSeconds > 0
? idleTimeoutSeconds * 1000
: appConfig.security.sessionTimeoutMs,
absoluteExpiryAt: Number.isFinite(absolute) ? absolute : null,
};
lastServerContactAt = Date.now();
lastInteractionAt = Date.now();
},
clear() {
window = {
idleTimeoutMs: appConfig.security.sessionTimeoutMs,
absoluteExpiryAt: null,
};
lastServerContactAt = Date.now();
lastInteractionAt = Date.now();
},
/** An authenticated call completed, so the server's idle window just reset. */
touch() {
lastActivityAt = Date.now();
lastServerContactAt = Date.now();
lastInteractionAt = Date.now();
},
/**
* The user touched the screen. Deliberately does not reset the expiry clock:
* the server has not heard from us, so pretending otherwise would leave the
* app believing it is signed in while the next real request 401s. It only
* makes `shouldKeepAlive()` true, and the keep-alive call is what actually
* moves the server's window.
*/
noteInteraction() {
lastInteractionAt = Date.now();
},
/**
* Whether to spend a keep-alive call now.
*
* True only when the user has done something since we last spoke to the
* server and the idle window is over halfway gone. The halfway threshold is
* what keeps this to roughly one request per two and a half minutes instead
* of one per tap.
*/
shouldKeepAlive() {
if (lastInteractionAt <= lastServerContactAt) {
return false;
}
// Nothing to preserve if the session is already gone, and no point
// extending the idle window when the absolute cap is what will bite.
if (this.hasExpired() || this.getAbsoluteRemainingMs() <= window.idleTimeoutMs / 2) {
return false;
}
return this.getIdleDurationMs() >= window.idleTimeoutMs / 2;
},
/**
* Which limit will end the session first. The warning copy depends on it:
* against the idle window carrying on using the app helps, and against the
* absolute cap nothing does, so telling the user to "tap to stay signed in"
* would be a lie.
*/
nearestLimit(): SessionExpiryReason {
return this.getAbsoluteRemainingMs() <= this.getIdleRemainingMs() ? 'absolute' : 'idle';
},
getIdleTimeoutMs() {
return window.idleTimeoutMs;
},
getIdleDurationMs() {
return Date.now() - lastActivityAt;
return Date.now() - lastServerContactAt;
},
getIdleRemainingMs() {
return Math.max(0, window.idleTimeoutMs - this.getIdleDurationMs());
},
getAbsoluteRemainingMs() {
if (window.absoluteExpiryAt === null) {
return Number.POSITIVE_INFINITY;
}
return Math.max(0, window.absoluteExpiryAt - Date.now());
},
/** Milliseconds until the session dies, whichever limit bites first. */
getRemainingMs() {
return Math.min(this.getIdleRemainingMs(), this.getAbsoluteRemainingMs());
},
expiryReason(): SessionExpiryReason | null {
if (this.getAbsoluteRemainingMs() <= 0) {
return 'absolute';
}
if (this.getIdleRemainingMs() <= 0) {
return 'idle';
}
return null;
},
hasExpired() {
return this.getIdleDurationMs() >= appConfig.security.sessionTimeoutMs;
return this.expiryReason() !== null;
},
};
+85
View File
@@ -0,0 +1,85 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
import { mpinCrypto } from '../security/mpinCrypto';
import { sessionManager } from '../security/sessionManager';
import { secureStorage } from './secureStorage';
/**
* The only AsyncStorage keys that survive a sign-out.
*
* The policy is deliberately inverted: everything is cleared unless it is named
* here, rather than a list of things to clear. A key added next month is then
* wiped by default instead of quietly outliving the customer who created it —
* which is the whole point of the guarantee, and the failure mode a
* clear-this-list approach has every time someone forgets to update it.
*
* None of the three exemptions is a property of a person:
*
* - the theme is a display choice made on this device, and flipping someone
* back to light mode because they signed out is a bug, not privacy;
* - the feature overrides are a tester's switches, and `featureOverrides` has
* always documented that they outlive a sign-out. They are configuration,
* and a bank kill switch still overrides anything set there;
* - the cached feature config is that kill switch. It is cached so a handset
* that starts offline still honours a feature the bank killed during an
* incident, and deleting it would make signing out a way to turn those
* features back on. Its one customer-scoped half, `roleOverrides`, is
* stripped out below instead — see `forgetRoleScope`.
*/
const KEPT_KEYS: readonly string[] = [
'@bankcbs/theme-mode',
'bankcbs.featureOverrides',
'bankcbs.featureConfig.lastKnown',
];
/**
* Removes everything this install holds about the person who was signed in.
*
* What it covers, and why each one had to be named:
*
* - the Keychain session, the remembered profile (their mobile number, org and
* name) and the **MPIN itself**, which biometric sign-in stores behind the
* device prompt. Only the session was being cleared before, so the other
* two survived every sign-out;
* - every AsyncStorage key but the three above — the biometric flag, payee
* favourites, and awareness and scheme bookmarks;
* - the customer's entitlements, in memory *and* in the config cache, which
* would otherwise be re-applied over whoever signs in next;
* - the in-memory caches, which no amount of storage clearing reaches: the
* session window and the fetched RSA key.
*
* What it deliberately does not touch is the device id. That is this install's
* identity rather than the customer's — the profile is bound to it at
* verify-otp, and every later sign-in has to send the same value or the server
* answers `DEVICE_NOT_BOUND`. Clearing it would not protect anyone; it would
* lock the handset out of the account it is registered to.
* `deviceIdentity.reset()` exists for the deliberate "register again" case.
*
* Nothing here may throw. A sign-out that fails halfway is worse than one that
* skips a key, so each step is isolated and the rest still runs.
*/
export const purgeUserData = async () => {
await Promise.allSettled([
secureStorage.clearSession(),
secureStorage.clearLastProfile(),
secureStorage.clearBiometricMpin(),
clearAsyncStorage(),
]);
// After the sweep, not beside it: it rewrites a key the sweep deliberately
// keeps, and the two racing would decide the file's contents by luck.
await featureRemoteConfig.forgetRoleScope().catch(() => {});
sessionManager.clear();
mpinCrypto.invalidate();
};
const clearAsyncStorage = async () => {
const keys = await AsyncStorage.getAllKeys();
const doomed = keys.filter((key) => !KEPT_KEYS.includes(key));
if (doomed.length > 0) {
await AsyncStorage.multiRemove(doomed);
}
};
+130 -11
View File
@@ -1,45 +1,164 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import type { AuthTokens } from '../../types/app';
import type { AuthSession } from '../../types/app';
const BIOMETRIC_KEY = 'bankcbs.biometricEnabled';
const SESSION_SERVICE = 'com.bankcbs.mobile.session';
/** The number and org last signed in with, so sign-in can prefill them. */
const PROFILE_SERVICE = 'com.bankcbs.mobile.lastProfile';
/**
* The MPIN, held only when the customer opts into biometric sign-in. The API
* has no refresh token — a session can only be opened by encrypting the MPIN —
* so biometric sign-in is impossible without keeping it. It lives behind the
* device's own biometric prompt and never in AsyncStorage.
*/
const BIOMETRIC_MPIN_SERVICE = 'com.bankcbs.mobile.biometricMpin';
export interface LastProfile {
orgId: string;
mobileNumber: string;
fullName?: string;
}
/**
* The request interceptor needs the token on every single call, and a Keystore
* read costs tens of milliseconds on Android. Held in memory alongside the
* Keychain copy, which stays the durable one across launches.
*/
let cachedSession: AuthSession | null = null;
let sessionCacheLoaded = false;
export const secureStorage = {
async saveSession(tokens: AuthTokens) {
await Keychain.setGenericPassword('session', JSON.stringify(tokens), {
async saveSession(session: AuthSession) {
await Keychain.setGenericPassword('session', JSON.stringify(session), {
service: SESSION_SERVICE,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
cachedSession = session;
sessionCacheLoaded = true;
},
async getSession(): Promise<AuthTokens | null> {
async getSession(): Promise<AuthSession | null> {
if (sessionCacheLoaded) {
return cachedSession;
}
const credentials = await Keychain.getGenericPassword({
service: SESSION_SERVICE,
});
if (!credentials) {
cachedSession = null;
sessionCacheLoaded = true;
return null;
}
try {
cachedSession = JSON.parse(credentials.password) as AuthSession;
sessionCacheLoaded = true;
return cachedSession;
} catch {
// A session written by an older build of the app. Treat it as absent
// rather than crashing the bootstrap.
await this.clearSession();
return null;
}
},
async getAccessToken() {
const session = await this.getSession();
return session?.sessionToken ?? null;
},
async clearSession() {
cachedSession = null;
sessionCacheLoaded = true;
await Keychain.resetGenericPassword({
service: SESSION_SERVICE,
});
},
async saveLastProfile(profile: LastProfile) {
await Keychain.setGenericPassword('lastProfile', JSON.stringify(profile), {
service: PROFILE_SERVICE,
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
},
async getLastProfile(): Promise<LastProfile | null> {
const credentials = await Keychain.getGenericPassword({
service: PROFILE_SERVICE,
});
if (!credentials) {
return null;
}
return JSON.parse(credentials.password) as AuthTokens;
try {
return JSON.parse(credentials.password) as LastProfile;
} catch {
return null;
}
},
async getAccessToken() {
const session = await this.getSession();
return session?.accessToken ?? null;
async clearLastProfile() {
await Keychain.resetGenericPassword({ service: PROFILE_SERVICE });
},
async clearSession() {
await Keychain.resetGenericPassword({
service: SESSION_SERVICE,
/**
* Stored under the platform's biometric access control, so reading it back
* raises the fingerprint / Face ID prompt rather than the app faking one.
*/
async saveBiometricMpin(mpin: string) {
await Keychain.setGenericPassword('mpin', mpin, {
service: BIOMETRIC_MPIN_SERVICE,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
},
async getBiometricMpin(promptTitle: string): Promise<string | null> {
try {
const credentials = await Keychain.getGenericPassword({
service: BIOMETRIC_MPIN_SERVICE,
authenticationPrompt: { title: promptTitle },
});
return credentials ? credentials.password : null;
} catch {
// Cancelled prompt, or no enrolled biometric any more.
return null;
}
},
async hasBiometricMpin() {
try {
const services = await Keychain.getAllGenericPasswordServices();
return services.includes(BIOMETRIC_MPIN_SERVICE);
} catch {
return false;
}
},
async clearBiometricMpin() {
await Keychain.resetGenericPassword({ service: BIOMETRIC_MPIN_SERVICE });
},
async setBiometricEnabled(value: boolean) {
await AsyncStorage.setItem(BIOMETRIC_KEY, JSON.stringify(value));
if (!value) {
await this.clearBiometricMpin();
}
},
async getBiometricEnabled() {