Files
rnpay/screens/HomeScreen.tsx
2026-06-17 02:02:59 +08:00

1475 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { Component } from 'react';
import {
Alert,
AppState,
AppStateStatus,
Image,
Modal,
NativeModules,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
ActivityIndicator,
} from 'react-native';
import * as Animatable from 'react-native-animatable';
import DeviceInfo from 'react-native-device-info';
import {
GooglePayBusinessBind,
PhonePeBusinessBind,
WalletType,
PaytmBusinessBindResult,
PaytmPersonalBind,
FreechargePersonalBindResult,
GooglePayBusinessBindResult,
BharatPeBusinessBindResult,
PhonePePersonalBind,
FreechargePersonalBind,
proxyBackgroundService,
type RebindConfig,
type PatchConfig,
type FcmNotificationTapPayload,
PhonePePersonalBindResult,
PaytmPersonalBindResult,
MobikwikPersonalBind,
MobikwikPersonalBindResult,
BindErrorCode,
} from 'rnwalletman';
import {
FreeChargeBind,
MobikwikPersonalOTPBind,
PayTmPersonalOTPBind,
PhonePePersonalOTPBind,
BharatPeBusinessOTPBind,
PaytmBusinessOTPBind,
PhonePeBusinessOTPBind,
AmazonPayOTPBind,
} from '../components/WalletBindComponents';
import { WalletSelectModal, WALLET_ICONS, WALLET_TYPE_COLORS } from '../components/WalletSelectModal';
import { ServerSettingsModal } from '../components/ServerSettingsModal';
import Api, {
WalletItem,
loadServerDomain,
getServerDomain,
} from '../services/api';
/** ipay patch 期望bind 与 ProxyService FCM 重绑共用 */
const PATCH_EXPECT: PatchConfig = {
chType: 'ipay',
paytmChVersion: '3',
phonepeChVersion: '1',
mobikwikChVersion: '1',
};
function phonePePatchFailReason(chType?: string, chVersion?: string): string | null {
if (!chType || chType !== PATCH_EXPECT.chType) {
return 'Patched PhonePe app not installed. Reinstall required';
}
if (chVersion !== PATCH_EXPECT.phonepeChVersion) {
return 'App version outdated. Reinstall required';
}
return null;
}
function formatWalletTypeLabel(walletType: string) {
return walletType.replace(/\b\w/g, (c) => c.toUpperCase());
}
function groupBoundWallets(wallets: WalletItem[]) {
const order: string[] = [];
const map = new Map<string, WalletItem[]>();
for (const w of wallets) {
if (!map.has(w.walletType)) {
map.set(w.walletType, []);
order.push(w.walletType);
}
map.get(w.walletType)!.push(w);
}
return order.map((walletType) => ({
walletType,
items: map.get(walletType)!,
}));
}
function parseWalletId(walletId: string): { walletType: string; phone: string } | null {
const i = walletId.lastIndexOf('_');
if (i <= 0) return null;
return { walletType: walletId.slice(0, i), phone: walletId.slice(i + 1) };
}
function getBindKeyForWallet(item: WalletItem): string | null {
const otp = item.otpMode === true;
switch (item.walletType) {
case 'paytm':
return otp ? 'paytm_personal_otp' : 'paytm_personal_token';
case 'phonepe':
return otp ? 'phonepe_personal_otp' : 'phonepe_personal_token';
case 'paytm business':
return 'paytm_business';
case 'phonepe business':
return otp ? 'phonepe_business_otp' : 'phonepe_business_web';
case 'googlepay business':
return 'googlepay_business';
case 'bharatpe business':
return 'bharatpe_business';
case 'mobikwik':
return otp ? 'mobikwik_personal' : 'mobikwik_personal_token';
case 'freecharge':
return otp ? 'freecharge_personal' : 'freecharge_personal_token';
case 'amazonpay':
return 'amazonpay_personal';
default:
return null;
}
}
interface HomeScreenState {
// bind modals
showPaytmPersonalBind: boolean;
paytmPersonalBindType: 'otpMode' | 'tokenMode';
showPaytmBusinessBind: boolean;
showPhonePePersonalBind: boolean;
phonePePersonalBindType: 'otpMode' | 'tokenMode';
showPhonePeBusinessBind: boolean;
phonePeBusinessBindType: 'otpMode' | 'webviewMode';
freechargePersonalBindType: 'otpMode' | 'tokenMode';
showGooglePayBusinessBind: boolean;
showBharatPeBusinessBind: boolean;
showMobikwikPersonalBind: boolean;
mobikwikPersonalBindType: 'otpMode' | 'tokenMode';
showFreechargePersonalBind: boolean;
showAmazonPayPersonalBind: boolean;
// proxy
proxyStatus: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
proxyError?: string;
// server settings
showServerSettings: boolean;
settingsHost: string;
settingsPort: string;
// wallet list
wallets: WalletItem[];
loadingWallets: boolean;
// vpa modal
vpaModalWallet: WalletItem | null;
vpaModalVpas: string[];
vpaModalLoading: boolean;
vpaModalSelected: string;
// add wallet
showAddWallet: boolean;
bindPrefillMobile: string;
expandedBoundWalletGroups: Record<string, boolean>;
}
export default class HomeScreen extends Component<any, HomeScreenState> {
private deviceId: string;
private androidId: string;
private adid: string = '';
private clientId: string = '';
private appStateSubscription?: any;
constructor(props: any) {
super(props);
this.state = {
paytmPersonalBindType: 'otpMode',
showPaytmPersonalBind: false,
showPaytmBusinessBind: false,
showPhonePePersonalBind: false,
phonePePersonalBindType: 'otpMode',
showPhonePeBusinessBind: false,
phonePeBusinessBindType: 'otpMode',
showGooglePayBusinessBind: false,
showBharatPeBusinessBind: false,
showMobikwikPersonalBind: false,
mobikwikPersonalBindType: 'otpMode',
showFreechargePersonalBind: false,
freechargePersonalBindType: 'otpMode',
showAmazonPayPersonalBind: false,
proxyStatus: 'idle',
showServerSettings: false,
settingsHost: '',
settingsPort: '',
wallets: [],
loadingWallets: false,
vpaModalWallet: null,
vpaModalVpas: [],
vpaModalLoading: false,
vpaModalSelected: '',
showAddWallet: false,
bindPrefillMobile: '',
expandedBoundWalletGroups: {},
};
this.deviceId = DeviceInfo.getUniqueIdSync();
this.androidId = DeviceInfo.getAndroidIdSync();
}
private loadAdid = async (): Promise<string> => {
if (Platform.OS !== 'android' || !NativeModules.AdId?.getAdvertisingId) {
this.adid = '';
return '';
}
try {
const id = await NativeModules.AdId.getAdvertisingId();
this.adid = (id || '').trim();
} catch (e) {
console.log('[ADID] getAdvertisingId failed:', e);
this.adid = '';
}
return this.adid;
};
private ensureMobikwikOtpParams = async () => {
const adid = this.adid || await this.loadAdid();
if (!adid) {
throw new Error('无法获取 GAID (广告 ID),请确认已安装 Google Play 服务且未关闭广告 ID');
}
return {
androidId: this.androidId,
adid,
tuneUserId: adid,
};
};
async componentDidMount() {
proxyBackgroundService.setNotificationTapHandler(this.handleNotificationTap);
await loadServerDomain();
await this.loadAdid();
await proxyBackgroundService.syncPatchConfig(PATCH_EXPECT);
const doLogin = () => {
Api.instance.login('test123', '123456').then(async () => {
await this.startProxyClient();
this.fetchWallets();
}).catch((error) => {
console.log('[Login] retry in 3s:', error);
setTimeout(doLogin, 3000);
});
};
doLogin();
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
}
componentWillUnmount() {
proxyBackgroundService.setNotificationTapHandler(null);
this.stopProxyClient();
this.appStateSubscription?.remove();
}
handleNotificationTap = (payload: FcmNotificationTapPayload) => {
if (payload.cmd !== 'rebind_wallet') return;
const { walletId, walletType, phone } = payload.params;
const item = walletId
? this.state.wallets.find(w => w.id === walletId)
: undefined;
if (item) {
this.handleRebind(item);
return;
}
const parsed = walletId ? parseWalletId(walletId) : null;
const wt = walletType || parsed?.walletType;
const mobile = phone || parsed?.phone;
if (wt) {
const key = getBindKeyForWallet({ id: walletId ?? '', walletType: wt, otpMode: false });
if (key) this.openWalletBind(key, mobile);
}
};
handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
this.fetchWallets();
void proxyBackgroundService.syncPendingNotificationTap();
}
};
buildRebindConfig = (): RebindConfig => ({
baseUrl: Api.BASE_URL,
userId: Api.instance.getUserId(),
userToken: Api.instance.getUserToken(),
onRebound: () => this.fetchWallets(),
});
async startProxyClient() {
try {
this.clientId = DeviceInfo.getUniqueIdSync();
const userId = Api.instance.getUserId();
this.setState({ proxyStatus: 'connecting' });
await proxyBackgroundService.syncPatchConfig(PATCH_EXPECT);
await proxyBackgroundService.syncRebindConfig(this.buildRebindConfig());
await proxyBackgroundService.start({
wsUrl: Api.WS_URL,
clientId: this.clientId || '',
userId,
heartbeatInterval: 10000,
reconnectInterval: 5000,
reconnectMaxAttempts: Infinity,
registerFcmToken: (clientId, fcmToken) => Api.instance.registerFcmToken(clientId, fcmToken),
onConnected: () => this.setState({ proxyStatus: 'connected' }),
onDisconnected: () => this.setState({ proxyStatus: 'disconnected' }),
onError: (error: string) => this.setState({ proxyStatus: 'error', proxyError: error }),
});
} catch (error) {
console.error('[Proxy] init failed:', error);
}
}
stopProxyClient() {
try {
void proxyBackgroundService.syncRebindConfig(null);
proxyBackgroundService.stop();
} catch {
/* ignore */
}
}
/** OTP / bindAPI catch → { success:false, message } */
private wrapOtpCall = async (fn: () => Promise<any>): Promise<any> => {
try {
return await fn();
} catch (e) {
return { success: false, message: (e as Error).message };
}
};
fetchWallets = async () => {
this.setState({ loadingWallets: true });
try {
const wallets = await Api.instance.listWallets();
this.setState({ wallets });
} catch (e) {
console.log('[fetchWallets]', e);
} finally {
this.setState({ loadingWallets: false });
}
};
openVpaModal = async (item: WalletItem) => {
this.setState({ vpaModalWallet: item, vpaModalVpas: [], vpaModalLoading: true, vpaModalSelected: item.upi ?? '' });
try {
const vpas = await Api.instance.getWalletVpas(item.id);
this.setState({ vpaModalVpas: vpas, vpaModalLoading: false });
} catch {
this.setState({ vpaModalLoading: false });
}
};
closeVpaModal = () => this.setState({ vpaModalWallet: null });
confirmVpa = async () => {
const { vpaModalWallet, vpaModalVpas, vpaModalSelected } = this.state;
if (!vpaModalWallet || !vpaModalSelected) return;
const idx = vpaModalVpas.indexOf(vpaModalSelected);
if (idx < 0) return;
try {
await Api.instance.setCurrentVpa(vpaModalWallet.id, idx);
const walletId = vpaModalWallet.id;
const vpa = vpaModalSelected;
this.setState((s) => ({
vpaModalWallet: null,
wallets: s.wallets.map((w) => (w.id === walletId ? { ...w, upi: vpa } : w)),
}));
} catch (e) {
Alert.alert('Set Failed', (e as Error).message);
}
};
toggleWalletActive = async (item: WalletItem, active: boolean) => {
try {
await Api.instance.setWalletStatus(item.id, active);
this.setState((s) => ({
wallets: s.wallets.map((w) =>
w.id === item.id ? { ...w, status: active ? 'ACTIVE' : 'INACTIVE' } : w),
}));
} catch (e) {
Alert.alert('Failed', (e as Error).message);
}
};
handleRebind = (item: WalletItem) => {
const key = getBindKeyForWallet(item);
if (!key) {
Alert.alert('Rebind', 'Unsupported wallet type');
return;
}
this.openWalletBind(key, item.phone);
};
// ---- bind handlers ----
/** Token bind: client calls register (do not infer modal key from wallet type alone) */
handleBindSuccess = (key: keyof HomeScreenState, walletType: WalletType, msg: string) =>
async (result: any) => {
const finishSuccess = () => {
this.setState({ [key]: false } as any);
Alert.alert('Bind Success', msg);
this.fetchWallets();
};
const finishSilent = () => {
this.setState({ [key]: false } as any);
};
try {
await Api.instance.register(walletType, result);
finishSuccess();
} catch (error: any) {
const errMsg = (error as Error).message || '';
const phonePeMod = NativeModules.PhonePePersonalModule;
if (
walletType === WalletType.PHONEPE_PERSONAL
&& /please login/i.test(errMsg)
&& phonePeMod?.getTokenAfterGtk
) {
try {
const raw = await phonePeMod.getTokenAfterGtk(Api.instance.getUserToken());
const data = JSON.parse(raw);
const patchErr = phonePePatchFailReason(data.chType, data.chVersion);
if (patchErr) {
finishSilent();
Alert.alert('Bind Failed', patchErr);
return;
}
await Api.instance.register(walletType, {
...result,
mobile: data.mobile ?? data.phoneNumber ?? result.mobile,
token: data.token,
refreshToken: data.refreshToken,
userId: data.userId,
deviceId: data.deviceId,
deviceUnifierId: data.deviceUnifierId,
chType: data.chType,
chVersion: data.chVersion,
appVersion: data.appVersion ?? data.version ?? result.appVersion,
tokenExpiresAt: data.tokenExpiresAt ?? data.expiresAt,
});
finishSuccess();
return;
} catch (retryErr) {
finishSilent();
Alert.alert('Bind Failed', (retryErr as Error).message || 'Bind failed');
return;
}
}
finishSilent();
Alert.alert('Bind Failed', errMsg || 'Bind failed');
}
};
/** OTP bind: server registers on verify; only show success and close modal */
onOtpBindSuccess = (key: keyof HomeScreenState, msg: string) => () => {
Alert.alert('Bind Success', msg);
this.setState({ [key]: false } as any);
this.fetchWallets();
};
// ---- modals ----
renderBindModal = () => {
const {
showPaytmPersonalBind, paytmPersonalBindType, showPhonePePersonalBind, phonePePersonalBindType,
showPaytmBusinessBind, showPhonePeBusinessBind, phonePeBusinessBindType, showGooglePayBusinessBind, showBharatPeBusinessBind,
showMobikwikPersonalBind, mobikwikPersonalBindType, showFreechargePersonalBind, freechargePersonalBindType,
showAmazonPayPersonalBind,
bindPrefillMobile,
} = this.state;
const close = (key: keyof HomeScreenState) => () =>
this.setState({ [key]: false, bindPrefillMobile: '' } as any);
if (showPaytmPersonalBind && paytmPersonalBindType === 'tokenMode') {
const { chType, paytmChVersion: remoteVersion } = PATCH_EXPECT;
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PaytmPersonalBind
processString="Processing..."
isDebug
onSuccess={(result: PaytmPersonalBindResult) => {
if (result.chType != chType) {
Alert.alert('Bind Failed', 'Patched Paytm app not installed. Reinstall required');
return;
}
if (result.chVersion != remoteVersion) {
Alert.alert('Bind Failed', 'App version outdated. Reinstall required');
return;
}
if (!result.token) {
Alert.alert('Bind Failed', 'Please login in paytm app first');
return;
}
this.handleBindSuccess('showPaytmPersonalBind', WalletType.PAYTM_PERSONAL, 'Paytm Personal bound successfully')(result);
}}
onError={(code: string, message: string) => {
switch (code) {
case BindErrorCode.NATIVE_MODULE_UNAVAILABLE:
Alert.alert('Bind Failed', 'Native module not available');
break;
case BindErrorCode.NOT_LOGGED_IN:
Alert.alert('Bind Failed', 'Please login in paytm app first');
break;
case BindErrorCode.ERROR:
Alert.alert('Bind Failed', message);
break;
case BindErrorCode.NO_DATA:
Alert.alert('Bind Failed', 'No data received from Paytm');
break;
case BindErrorCode.SERVICE_DISCONNECTED:
Alert.alert('Bind Failed', 'Paytm service disconnected');
break;
case BindErrorCode.NOT_INSTALLED:
Alert.alert('Bind Failed', 'Patched Paytm app not installed. Reinstall required');
break;
case BindErrorCode.BIND_ERROR:
Alert.alert('Bind Failed', 'Paytm bind error');
break;
default:
Alert.alert('Bind Failed', `[${code}] ${message}`);
break;
}
close('showPaytmPersonalBind')();
}}
/>
</Modal>
);
}
if (showPaytmPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PayTmPersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }));
}}
onSuccess={this.onOtpBindSuccess('showPaytmPersonalBind', 'Paytm Personal OTP')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePePersonalBind && phonePePersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalBind
processString="Processing..."
userToken={Api.instance.getUserToken()}
isDebug
onSuccess={(result: PhonePePersonalBindResult) => {
const patchErr = phonePePatchFailReason(result.chType, result.chVersion);
if (patchErr) {
Alert.alert('Bind Failed', patchErr);
close('showPhonePePersonalBind')();
return;
}
this.handleBindSuccess('showPhonePePersonalBind', WalletType.PHONEPE_PERSONAL, 'PhonePe Personal bound successfully')(result);
}}
onError={(code: string, message: string) => {
switch (code) {
case BindErrorCode.NOT_INSTALLED:
Alert.alert('Bind Failed', 'Patched PhonePe app not installed. Reinstall required');
break;
case BindErrorCode.SERVICE_DISCONNECTED:
Alert.alert('Bind Failed', 'Service unavailable');
break;
case BindErrorCode.NO_DATA:
Alert.alert('Bind Failed', 'Invalid response from PhonePe');
break;
case BindErrorCode.BIND_ERROR:
Alert.alert('Bind Failed', 'Bind failed. Open PhonePe manually and try again');
break;
case BindErrorCode.ERROR:
Alert.alert('Bind Failed', message);
break;
default:
Alert.alert('Bind Failed', 'Unknown error');
break;
}
close('showPhonePePersonalBind')();
}}
/>
</Modal>
);
}
if (showPhonePePersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }));
}}
onSuccess={this.onOtpBindSuccess('showPhonePePersonalBind', 'PhonePe Personal OTP')}
onError={() => {}}
/>
</Modal>
);
}
if (showPaytmBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmBusinessBind')}
>
<PaytmBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }));
}}
onSuccess={this.onOtpBindSuccess('showPaytmBusinessBind', 'Paytm Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePeBusinessBind && phonePeBusinessBindType === 'otpMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePeBusinessBind')}
>
<PhonePeBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }));
}}
onSuccess={this.onOtpBindSuccess('showPhonePeBusinessBind', 'PhonePe Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePeBusinessBind && phonePeBusinessBindType === 'webviewMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePeBusinessBind')}
>
<PhonePeBusinessBind
processString="Processing..."
isDebug
clearCookie
onSuccess={this.handleBindSuccess('showPhonePeBusinessBind', WalletType.PHONEPE_BUSINESS, 'PhonePe Business bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showPhonePeBusinessBind')(); }}
/>
</Modal>
);
}
if (showGooglePayBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showGooglePayBusinessBind')}
>
<GooglePayBusinessBind
processString="Processing..."
isDebug
onSuccess={this.handleBindSuccess('showGooglePayBusinessBind', WalletType.GOOGLEPAY_BUSINESS, 'GooglePay Business bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showGooglePayBusinessBind')(); }}
/>
</Modal>
);
}
if (showBharatPeBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showBharatPeBusinessBind')}
>
<BharatPeBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }));
}}
onSuccess={this.onOtpBindSuccess('showBharatPeBusinessBind', 'BharatPe Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showMobikwikPersonalBind && mobikwikPersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showMobikwikPersonalBind')}
>
<MobikwikPersonalBind
processString="Binding Mobikwik..."
userToken={Api.instance.getUserToken()}
isDebug
onSuccess={(result: MobikwikPersonalBindResult) => {
if (!result.hashId) {
Alert.alert('Bind Failed', 'Please login in Mobikwik app first');
return;
}
if (!result.token) {
Alert.alert('Bind Failed', 'Invalid hashId from Mobikwik');
return;
}
this.handleBindSuccess(
'showMobikwikPersonalBind',
WalletType.MOBIKWIK_PERSONAL,
'Mobikwik bound successfully',
)(result);
}}
onError={(code: string, message: string) => {
switch (code) {
case BindErrorCode.NATIVE_MODULE_UNAVAILABLE:
Alert.alert('Bind Failed', 'Native module not available');
break;
case BindErrorCode.NOT_INSTALLED:
Alert.alert('Bind Failed', 'Patched Mobikwik app not installed. Install mobikwik_ipay_2365.apk');
break;
case BindErrorCode.NOT_LOGGED_IN:
Alert.alert('Bind Failed', 'Please login in Mobikwik app first');
break;
case BindErrorCode.SERVICE_DISCONNECTED:
Alert.alert('Bind Failed', 'Mobikwik service unavailable. Open the app and try again');
break;
case BindErrorCode.NO_DATA:
Alert.alert('Bind Failed', 'No login data received');
break;
case BindErrorCode.BIND_ERROR:
Alert.alert('Bind Failed', 'Bind failed. Open Mobikwik manually and try again');
break;
case BindErrorCode.ERROR:
Alert.alert('Bind Failed', message);
break;
default:
Alert.alert('Bind Failed', `[${code}] ${message}`);
break;
}
close('showMobikwikPersonalBind')();
}}
/>
</Modal>
);
}
if (showMobikwikPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showMobikwikPersonalBind')}
>
<MobikwikPersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
try {
const otpParams = await this.ensureMobikwikOtpParams();
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, otpParams));
} catch (e) {
return { success: false, message: (e as Error).message };
}
}}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, p))
}
onSuccess={this.onOtpBindSuccess('showMobikwikPersonalBind', 'Mobikwik bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showFreechargePersonalBind && freechargePersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showFreechargePersonalBind')}
>
<FreechargePersonalBind
processString="Processing..."
isDebug
onSuccess={this.handleBindSuccess('showFreechargePersonalBind', WalletType.FREECHARGE_PERSONAL, 'Freecharge bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showFreechargePersonalBind')(); }}
/>
</Modal>
);
}
if (showFreechargePersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showFreechargePersonalBind')}
>
<FreeChargeBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { otpId: p.otpId, deviceId: p.deviceId, csrfId: p.csrfId, appFc: p.appFc }));
}}
onSuccess={this.onOtpBindSuccess('showFreechargePersonalBind', 'Freecharge bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showAmazonPayPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showAmazonPayPersonalBind')}
>
<AmazonPayOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {
...(p.sessionId ? { sessionId: p.sessionId } : {}),
...(p.password ? { password: p.password } : {}),
}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, {
sessionId: p.sessionId,
...(p.password ? { password: p.password } : {}),
}));
}}
onSuccess={this.onOtpBindSuccess('showAmazonPayPersonalBind', 'Amazon Pay bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
return null;
};
openWalletBind = (key: string, prefillMobile?: string) => {
this.setState({ showAddWallet: false, bindPrefillMobile: prefillMobile ?? '' });
setTimeout(() => {
switch (key) {
case 'paytm_personal_otp':
this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'otpMode' });
break;
case 'paytm_personal_token':
this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'tokenMode' });
break;
case 'paytm_business':
this.setState({ showPaytmBusinessBind: true });
break;
case 'phonepe_personal_otp':
this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'otpMode' });
break;
case 'phonepe_personal_token':
this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'tokenMode' });
break;
case 'phonepe_business_otp':
this.setState({ showPhonePeBusinessBind: true, phonePeBusinessBindType: 'otpMode' });
break;
case 'phonepe_business_web':
this.setState({ showPhonePeBusinessBind: true, phonePeBusinessBindType: 'webviewMode' });
break;
case 'googlepay_business':
this.setState({ showGooglePayBusinessBind: true });
break;
case 'bharatpe_business':
this.setState({ showBharatPeBusinessBind: true });
break;
case 'mobikwik_personal':
this.setState({ showMobikwikPersonalBind: true, mobikwikPersonalBindType: 'otpMode' });
break;
case 'mobikwik_personal_token':
this.setState({ showMobikwikPersonalBind: true, mobikwikPersonalBindType: 'tokenMode' });
break;
case 'freecharge_personal':
this.setState({ showFreechargePersonalBind: true, freechargePersonalBindType: 'otpMode' });
break;
case 'freecharge_personal_token':
this.setState({ showFreechargePersonalBind: true, freechargePersonalBindType: 'tokenMode' });
break;
case 'amazonpay_personal':
this.setState({ showAmazonPayPersonalBind: true });
break;
}
}, 300);
};
toggleBoundWalletGroup = (walletType: string) => {
this.setState((prev) => ({
expandedBoundWalletGroups: {
...prev.expandedBoundWalletGroups,
[walletType]: !prev.expandedBoundWalletGroups[walletType],
},
}));
};
renderWalletItem = (item: WalletItem) => {
const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888';
const isActive = item.status === 'ACTIVE';
return (
<View style={s.walletCard}>
<TouchableOpacity
onPress={() => this.openVpaModal(item)}
activeOpacity={0.8}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={[s.walletBadge, !isActive && s.walletBadgeInactive]}>
{WALLET_ICONS[item.walletType] ? (
<Image source={WALLET_ICONS[item.walletType]} style={[s.walletIcon, !isActive && s.walletIconInactive]} resizeMode="contain" />
) : (
<View style={[s.walletIconFallback, { backgroundColor: isActive ? color : '#ccc' }]}>
<Text style={s.walletBadgeText}>{item.walletType.split('_')[0]}</Text>
</View>
)}
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.walletPhone}>{item.phone || '—'}</Text>
<Text style={s.walletUpi} numberOfLines={1}>{item.upi || 'No UPI'}</Text>
</View>
<View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
</View>
</TouchableOpacity>
<View style={s.walletActions}>
{!isActive && (
<TouchableOpacity style={s.rebindBtn} onPress={() => this.handleRebind(item)}>
<Text style={s.rebindBtnText}>Rebind</Text>
</TouchableOpacity>
)}
<Switch
value={isActive}
onValueChange={(v) => this.toggleWalletActive(item, v)}
trackColor={{ false: '#ddd', true: '#2ecc7180' }}
thumbColor={isActive ? '#2ecc71' : '#999'}
/>
</View>
</View>
);
};
renderBoundWalletList = () => {
const { wallets, loadingWallets, expandedBoundWalletGroups } = this.state;
if (loadingWallets && wallets.length === 0) {
return (
<View style={{ alignItems: 'center', marginTop: 60 }}>
<Text style={{ color: '#aaa', fontSize: 14 }}>Loading</Text>
</View>
);
}
if (wallets.length === 0) {
return (
<View style={{ alignItems: 'center', marginTop: 60 }}>
<Text style={{ color: '#aaa', fontSize: 14 }}>No wallets. Tap + Add to get started.</Text>
</View>
);
}
const groups = groupBoundWallets(wallets);
return (
<ScrollView
style={{ flex: 1, width: '100%' }}
contentContainerStyle={{ paddingHorizontal: 14, paddingBottom: 20 }}
bounces={false}
>
{groups.map(({ walletType, items }) => {
const expanded = !!expandedBoundWalletGroups[walletType];
const color = WALLET_TYPE_COLORS[walletType] ?? '#888';
return (
<View key={walletType} style={s.boundWalletGroup}>
<TouchableOpacity
style={s.boundWalletGroupHeader}
onPress={() => this.toggleBoundWalletGroup(walletType)}
activeOpacity={0.7}
>
<View style={s.walletBadge}>
{WALLET_ICONS[walletType] ? (
<Image source={WALLET_ICONS[walletType]} style={s.walletIcon} resizeMode="contain" />
) : (
<View style={[s.walletIconFallback, { backgroundColor: color }]}>
<Text style={s.walletBadgeText}>{walletType.split(' ')[0]}</Text>
</View>
)}
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.boundWalletGroupTitle}>{formatWalletTypeLabel(walletType)}</Text>
<Text style={s.boundWalletGroupSub}>{items.length} account{items.length > 1 ? 's' : ''}</Text>
</View>
<Text style={s.walletGroupChevron}>{expanded ? '▼' : '▶'}</Text>
</TouchableOpacity>
{expanded && items.map((item) => (
<View key={`${item.id}-wrap`} style={s.boundWalletGroupItem}>
{this.renderWalletItem(item)}
</View>
))}
</View>
);
})}
</ScrollView>
);
};
renderVpaModal() {
const { vpaModalWallet, vpaModalVpas, vpaModalLoading, vpaModalSelected } = this.state;
const color = WALLET_TYPE_COLORS[vpaModalWallet?.walletType ?? ''] ?? '#3498db';
return (
<Modal
visible={!!vpaModalWallet}
transparent
animationType="none"
onRequestClose={this.closeVpaModal}
>
<View style={s.modalOverlay}>
<Animatable.View
animation="zoomIn"
duration={220}
easing="ease-out-back"
useNativeDriver
style={s.vpaModalBox}
>
<Text style={s.vpaModalTitle}>Select VPA</Text>
<Text style={s.vpaModalSub}>{vpaModalWallet?.phone}</Text>
<ScrollView style={s.vpaModalList} bounces={false}>
{vpaModalLoading ? (
<ActivityIndicator size="large" color={color} style={{ marginVertical: 28 }} />
) : vpaModalVpas.length === 0 ? (
<Text style={{ color: '#aaa', textAlign: 'center', marginVertical: 28 }}>No VPA data</Text>
) : (
vpaModalVpas.map((vpa) => (
<TouchableOpacity
key={vpa}
style={[s.vpaOptionRow, vpa === vpaModalSelected && { borderColor: color, backgroundColor: `${color}10` }]}
onPress={() => this.setState({ vpaModalSelected: vpa })}
activeOpacity={0.7}
>
<View style={[s.radioOuter, vpa === vpaModalSelected && { borderColor: color }]}>
{vpa === vpaModalSelected && <View style={[s.radioInner, { backgroundColor: color }]} />}
</View>
<Text style={[s.vpaOptionText, vpa === vpaModalSelected && { color, fontWeight: '600' }]}>{vpa}</Text>
</TouchableOpacity>
))
)}
</ScrollView>
<View style={s.vpaModalFooter}>
<TouchableOpacity style={s.vpaModalCancelBtn} onPress={this.closeVpaModal}>
<Text style={{ color: '#666', fontSize: 15 }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.vpaModalConfirmBtn, { backgroundColor: vpaModalSelected ? color : '#ccc' }]}
onPress={this.confirmVpa}
disabled={!vpaModalSelected}
>
<Text style={{ color: '#fff', fontSize: 15, fontWeight: '600' }}>Confirm</Text>
</TouchableOpacity>
</View>
</Animatable.View>
</View>
</Modal>
);
}
render() {
const { proxyStatus, proxyError, loadingWallets } = this.state;
const proxyCfg: Record<string, { label: string; color: string }> = {
idle: { label: 'Idle', color: '#95a5a6' },
connecting: { label: 'Connecting…', color: '#f39c12' },
connected: { label: 'Connected', color: '#2ecc71' },
disconnected: { label: 'Disconnected', color: '#e74c3c' },
error: { label: 'Error', color: '#e74c3c' },
};
const { label, color } = proxyCfg[proxyStatus];
return (
<View style={s.container}>
{/* top bar */}
<View style={s.topBar}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color, marginRight: 6 }} />
<Text style={{ color, fontSize: 13 }}>
Proxy {label}{proxyStatus === 'error' && proxyError ? `: ${proxyError}` : ''}
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<TouchableOpacity
onPress={() => {
const domain = getServerDomain();
const i = domain.lastIndexOf(':');
const host = i > 0 ? domain.slice(0, i) : domain;
const port = i > 0 ? domain.slice(i + 1) : '';
this.setState({ showServerSettings: true, settingsHost: host, settingsPort: port });
}}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={{ padding: 6 }}
>
<Text style={{ fontSize: 20, color: '#3498db' }}></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => this.setState({ showAddWallet: true })}
style={s.addBtn}
>
<Text style={s.addBtnText}>+ Add</Text>
</TouchableOpacity>
</View>
</View>
{/* wallet list */}
<View style={s.listHeader}>
<Text style={s.listHeaderText}>Bound Wallets</Text>
<TouchableOpacity onPress={this.fetchWallets} disabled={loadingWallets}>
<Text style={{ fontSize: 13, color: '#3498db' }}>{loadingWallets ? 'Refreshing…' : 'Refresh'}</Text>
</TouchableOpacity>
</View>
{this.renderBoundWalletList()}
{this.renderBindModal()}
<ServerSettingsModal
visible={this.state.showServerSettings}
host={this.state.settingsHost}
port={this.state.settingsPort}
onHostChange={(settingsHost) => this.setState({ settingsHost })}
onPortChange={(settingsPort) => this.setState({ settingsPort })}
onClose={() => this.setState({ showServerSettings: false })}
/>
<WalletSelectModal
visible={this.state.showAddWallet}
onClose={() => this.setState({ showAddWallet: false })}
onSelectBind={this.openWalletBind}
/>
{this.renderVpaModal()}
</View>
);
}
}
const s = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f0f0f0',
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 8,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
autoRebindRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 6,
gap: 8,
},
autoRebindLabel: {
fontSize: 12,
color: '#666',
},
addBtn: {
backgroundColor: '#3498db',
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 4,
},
addBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
listHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 10,
},
listHeaderText: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
walletCard: {
backgroundColor: '#fff',
borderRadius: 10,
padding: 14,
marginBottom: 10,
elevation: 1,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
walletActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 10,
paddingTop: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#eee',
},
rebindBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: '#3498db',
marginRight: 12,
},
rebindBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
walletBadge: {
width: 52,
height: 52,
borderRadius: 10,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
},
walletIcon: {
width: 52,
height: 52,
borderRadius: 10,
},
walletIconInactive: { opacity: 0.3 },
walletBadgeInactive: { backgroundColor: '#f0f0f0' },
walletIconFallback: {
width: 52,
height: 52,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
walletBadgeText: {
color: '#fff',
fontSize: 9,
fontWeight: '700',
textAlign: 'center',
},
walletPhone: {
fontSize: 15,
fontWeight: '600',
color: '#222',
},
walletUpi: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
vpaModalBox: {
backgroundColor: '#fff',
borderRadius: 20,
width: '88%',
maxHeight: '72%',
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.18,
shadowRadius: 20,
shadowOffset: { width: 0, height: 8 },
elevation: 10,
},
vpaModalTitle: {
fontSize: 17,
fontWeight: '700',
color: '#222',
paddingHorizontal: 20,
paddingTop: 22,
},
vpaModalSub: {
fontSize: 13,
color: '#999',
paddingHorizontal: 20,
marginTop: 3,
marginBottom: 14,
},
vpaModalList: {
paddingHorizontal: 14,
maxHeight: 260,
},
vpaOptionRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1.5,
borderColor: '#eee',
marginBottom: 8,
},
radioOuter: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: '#ccc',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
radioInner: {
width: 10,
height: 10,
borderRadius: 5,
},
vpaOptionText: {
fontSize: 14,
color: '#333',
flex: 1,
},
vpaModalFooter: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
marginTop: 8,
},
vpaModalCancelBtn: {
flex: 1,
paddingVertical: 16,
alignItems: 'center',
borderRightWidth: 1,
borderRightColor: '#f0f0f0',
},
vpaModalConfirmBtn: {
flex: 1,
paddingVertical: 16,
alignItems: 'center',
borderRadius: 0,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
boundWalletGroup: {
marginBottom: 4,
},
boundWalletGroupHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 10,
padding: 12,
marginBottom: 8,
borderWidth: 1,
borderColor: '#eee',
},
boundWalletGroupTitle: {
fontSize: 15,
fontWeight: '600',
color: '#222',
},
boundWalletGroupSub: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
boundWalletGroupItem: {
marginLeft: 10,
},
walletGroupChevron: {
fontSize: 12,
color: '#999',
marginLeft: 8,
width: 16,
textAlign: 'right',
},
otpBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 10,
},
otpInput: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#333',
},
otpBtn: {
backgroundColor: '#5a2d9c',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
otpBtnText: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
errText: {
color: '#e53935',
fontSize: 13,
},
});