fix sms otp retriver

This commit is contained in:
2026-05-31 00:05:21 +08:00
parent 80bf462d2c
commit 557931892f
6 changed files with 168 additions and 74 deletions

View File

@@ -6,7 +6,9 @@ import {
Text, Text,
StyleSheet, StyleSheet,
ActivityIndicator, ActivityIndicator,
Platform,
} from 'react-native'; } from 'react-native';
import { normalizeHintPhone, requestPhoneNumberHint } from '../utils/smsRetriever';
import { WalletType } from 'rnwalletman'; import { WalletType } from 'rnwalletman';
import { useOTPBind } from '../hooks/useOTPBind'; import { useOTPBind } from '../hooks/useOTPBind';
@@ -57,6 +59,29 @@ export const OTPBindUI: React.FC<OTPBindUIProps> = ({
const isLoading = state.loading || state.step === 'processing'; const isLoading = state.loading || state.step === 'processing';
const pickPhoneFromSystem = async () => {
if (Platform.OS !== 'android' || isLoading) return;
try {
const raw = await requestPhoneNumberHint();
const digits = normalizeHintPhone(raw, mobileLength);
if (digits.length === mobileLength) {
actions.setMobile(digits);
if (state.errorMessage) actions.clearError();
}
} catch {
/* 用户取消 */
}
};
const renderPickPhone = () => {
if (Platform.OS !== 'android') return null;
return (
<TouchableOpacity style={styles.linkBtn} onPress={pickPhoneFromSystem} disabled={isLoading}>
<Text style={[styles.linkText, isLoading && styles.linkTextDisabled]}></Text>
</TouchableOpacity>
);
};
const renderResendOtp = () => { const renderResendOtp = () => {
if (!state.otpSent) return null; if (!state.otpSent) return null;
const disabled = isLoading || state.resendCountdown > 0; const disabled = isLoading || state.resendCountdown > 0;
@@ -98,6 +123,7 @@ export const OTPBindUI: React.FC<OTPBindUIProps> = ({
}} }}
editable={!state.formStarted && !isLoading} editable={!state.formStarted && !isLoading}
/> />
{!state.formStarted && renderPickPhone()}
{state.formStarted && state.passwordRequired && ( {state.formStarted && state.passwordRequired && (
<> <>
<Text style={styles.label}>{passwordLabel}</Text> <Text style={styles.label}>{passwordLabel}</Text>
@@ -206,6 +232,7 @@ export const OTPBindUI: React.FC<OTPBindUIProps> = ({
}} }}
editable={!isLoading} editable={!isLoading}
/> />
{renderPickPhone()}
<TouchableOpacity <TouchableOpacity
style={[styles.btn, isLoading && styles.btnDisabled]} style={[styles.btn, isLoading && styles.btnDisabled]}
onPress={actions.requestOTP} onPress={actions.requestOTP}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import { WalletType } from 'rnwalletman'; import { WalletType } from 'rnwalletman';
import { startOtpSmsListener } from '../utils/smsRetriever';
export interface OTPBindCallbacks { export interface OTPBindCallbacks {
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>; onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
@@ -57,6 +59,8 @@ export function useOTPBind(
const [otpData, setOtpData] = useState<any>(null); const [otpData, setOtpData] = useState<any>(null);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [resendCountdown, setResendCountdown] = useState(0); const [resendCountdown, setResendCountdown] = useState(0);
const [smsListenKey, setSmsListenKey] = useState(0);
const smsStopRef = useRef<(() => void) | null>(null);
const { onRequestOTP, onVerifyOTP, onSuccess, onError, isDebug = false } = callbacks; const { onRequestOTP, onVerifyOTP, onSuccess, onError, isDebug = false } = callbacks;
const { const {
@@ -73,6 +77,14 @@ export function useOTPBind(
if (initialMobile) setMobile(initialMobile); if (initialMobile) setMobile(initialMobile);
}, [initialMobile]); }, [initialMobile]);
const log = (...args: any[]) => {
if (isDebug) console.log(`[${walletType}]`, ...args);
};
const error = (...args: any[]) => {
if (isDebug) console.error(`[${walletType}]`, ...args);
};
useEffect(() => { useEffect(() => {
if (resendCountdown <= 0) return; if (resendCountdown <= 0) return;
const timer = setInterval(() => { const timer = setInterval(() => {
@@ -81,18 +93,43 @@ export function useOTPBind(
return () => clearInterval(timer); return () => clearInterval(timer);
}, [resendCountdown]); }, [resendCountdown]);
const shouldListenSms =
Platform.OS === 'android' &&
(step === 'otp' || (passwordBeforeOtp && otpSent && formStarted));
useEffect(() => {
if (!shouldListenSms) {
smsStopRef.current?.();
smsStopRef.current = null;
return;
}
let cancelled = false;
(async () => {
smsStopRef.current?.();
const stop = await startOtpSmsListener(otpLength, (code) => {
if (cancelled) return;
setOtp(code);
log('OTP from SMS Retriever:', code);
});
if (cancelled) {
stop();
return;
}
smsStopRef.current = stop;
})();
return () => {
cancelled = true;
smsStopRef.current?.();
smsStopRef.current = null;
};
}, [shouldListenSms, smsListenKey, otpLength]);
useEffect(() => () => smsStopRef.current?.(), []);
const startResendCooldown = () => setResendCountdown(resendCooldown); const startResendCooldown = () => setResendCountdown(resendCooldown);
const clearError = () => setErrorMessage(''); const clearError = () => setErrorMessage('');
const log = (...args: any[]) => {
if (isDebug) console.log(`[${walletType}]`, ...args);
};
const error = (...args: any[]) => {
if (isDebug) console.error(`[${walletType}]`, ...args);
};
const requestOTP = async () => { const requestOTP = async () => {
const withPassword = passwordBeforeOtp && formStarted && passwordRequired; const withPassword = passwordBeforeOtp && formStarted && passwordRequired;
const isResend = otpSent && formStarted; const isResend = otpSent && formStarted;
@@ -136,6 +173,7 @@ export function useOTPBind(
} else { } else {
setPasswordRequired(!!response.data?.needPassword || withPassword); setPasswordRequired(!!response.data?.needPassword || withPassword);
setOtpSent(true); setOtpSent(true);
setSmsListenKey((k) => k + 1);
startResendCooldown(); startResendCooldown();
if (!passwordBeforeOtp) { if (!passwordBeforeOtp) {
setStep('otp'); setStep('otp');
@@ -216,6 +254,9 @@ export function useOTPBind(
setOtpData(null); setOtpData(null);
setErrorMessage(''); setErrorMessage('');
setResendCountdown(0); setResendCountdown(0);
setSmsListenKey(0);
smsStopRef.current?.();
smsStopRef.current = null;
}; };
return [ return [

View File

@@ -26,6 +26,7 @@
"react-native-gesture-handler": "~2.9.0", "react-native-gesture-handler": "~2.9.0",
"react-native-safe-area-context": "^4.4.1", "react-native-safe-area-context": "^4.4.1",
"react-native-screens": "~3.36.0", "react-native-screens": "~3.36.0",
"react-native-sms-retriever": "1.1.1",
"react-native-svg": "^14.1.0", "react-native-svg": "^14.1.0",
"react-native-svg-transformer": "^1.5.3", "react-native-svg-transformer": "^1.5.3",
"react-native-tcp-socket": "^6.4.1", "react-native-tcp-socket": "^6.4.1",

View File

@@ -25,15 +25,10 @@ import {
FreechargePersonalBindResult, FreechargePersonalBindResult,
GooglePayBusinessBindResult, GooglePayBusinessBindResult,
BharatPeBusinessBindResult, BharatPeBusinessBindResult,
onSmsMessage,
startSmsListener,
stopSmsListener,
checkSmsPermission,
requestSmsPermission,
PhonePePersonalBind, PhonePePersonalBind,
FreechargePersonalBind, FreechargePersonalBind,
SmsMessage,
proxyBackgroundService, proxyBackgroundService,
type TokenAutoRebindDeps,
PhonePePersonalBindResult, PhonePePersonalBindResult,
PaytmPersonalBindResult, PaytmPersonalBindResult,
BindErrorCode, BindErrorCode,
@@ -58,10 +53,8 @@ import Api, {
loadServerDomain, loadServerDomain,
getServerDomain, getServerDomain,
getTokenAutoRebindEnabled, getTokenAutoRebindEnabled,
getTokenAutoRebindDebug,
getTokenAutoRebindOptions, getTokenAutoRebindOptions,
saveTokenAutoRebindEnabled, saveTokenAutoRebindEnabled,
saveTokenAutoRebindDebug,
} from '../services/api'; } from '../services/api';
function formatWalletTypeLabel(walletType: string) { function formatWalletTypeLabel(walletType: string) {
@@ -130,7 +123,6 @@ interface HomeScreenState {
proxyStatus: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'; proxyStatus: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
proxyError?: string; proxyError?: string;
tokenAutoRebind: boolean; tokenAutoRebind: boolean;
tokenAutoRebindDebug: boolean;
// server settings // server settings
showServerSettings: boolean; showServerSettings: boolean;
settingsHost: string; settingsHost: string;
@@ -178,7 +170,6 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
showAmazonPayPersonalBind: false, showAmazonPayPersonalBind: false,
proxyStatus: 'idle', proxyStatus: 'idle',
tokenAutoRebind: false, tokenAutoRebind: false,
tokenAutoRebindDebug: false,
showServerSettings: false, showServerSettings: false,
settingsHost: '', settingsHost: '',
settingsPort: '', settingsPort: '',
@@ -200,11 +191,8 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
async componentDidMount() { async componentDidMount() {
await loadServerDomain(); await loadServerDomain();
const tokenAutoRebind = getTokenAutoRebindEnabled(); const tokenAutoRebind = getTokenAutoRebindEnabled();
const tokenAutoRebindDebug = getTokenAutoRebindDebug(); this.setState({ tokenAutoRebind });
this.setState({ tokenAutoRebind, tokenAutoRebindDebug });
proxyBackgroundService.setTokenAutoRebindEnabled(tokenAutoRebind); proxyBackgroundService.setTokenAutoRebindEnabled(tokenAutoRebind);
proxyBackgroundService.setTokenAutoRebindDebugLog(tokenAutoRebindDebug);
await this.setupPermissions();
const doLogin = () => { const doLogin = () => {
Api.instance.login('test123', '123456').then(async () => { Api.instance.login('test123', '123456').then(async () => {
@@ -221,7 +209,6 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
componentWillUnmount() { componentWillUnmount() {
this.stopProxyClient(); this.stopProxyClient();
stopSmsListener();
this.appStateSubscription?.remove(); this.appStateSubscription?.remove();
} }
@@ -229,14 +216,17 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
if (nextAppState === 'active') this.fetchWallets(); if (nextAppState === 'active') this.fetchWallets();
}; };
async setupPermissions() { buildTokenAutoRebindDeps = (): TokenAutoRebindDeps => ({
const hasSms = await checkSmsPermission(); listWallets: () => Api.instance.listWallets(),
if (!hasSms) await requestSmsPermission(); register: (_wallet, walletType: WalletType, params: Record<string, unknown>) =>
startSmsListener(); Api.instance.register(walletType, params),
onSmsMessage((msg: SmsMessage) => { getUserToken: () => Api.instance.getUserToken(),
console.log('[SMS]', msg.address, msg.body); onRebound: () => this.fetchWallets(),
isActive: async (w) => w.status === 'ACTIVE' || w.otpMode === true,
log: this.state.tokenAutoRebind
? (...args: unknown[]) => console.log('[TokenAutoRebind]', ...args)
: undefined,
}); });
}
async startProxyClient() { async startProxyClient() {
try { try {
@@ -244,18 +234,10 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
const userId = Api.instance.getUserId(); const userId = Api.instance.getUserId();
this.setState({ proxyStatus: 'connecting' }); this.setState({ proxyStatus: 'connecting' });
proxyBackgroundService.configureTokenAutoRebind( proxyBackgroundService.configureTokenAutoRebind(
{ this.buildTokenAutoRebindDeps(),
listWallets: () => Api.instance.listWallets(),
register: (_wallet, walletType: WalletType, params: Record<string, unknown>) =>
Api.instance.register(walletType, params),
getUserToken: () => Api.instance.getUserToken(),
onRebound: () => this.fetchWallets(),
isActive: async (w) => w.status === 'ACTIVE' || w.otpMode === true,
},
getTokenAutoRebindOptions(), getTokenAutoRebindOptions(),
); );
proxyBackgroundService.setTokenAutoRebindEnabled(this.state.tokenAutoRebind); proxyBackgroundService.setTokenAutoRebindEnabled(this.state.tokenAutoRebind);
proxyBackgroundService.setTokenAutoRebindDebugLog(this.state.tokenAutoRebindDebug);
await proxyBackgroundService.start({ await proxyBackgroundService.start({
wsUrl: Api.WS_URL, clientId: this.clientId || '', userId, wsUrl: Api.WS_URL, clientId: this.clientId || '', userId,
debug: true, heartbeatInterval: 10000, reconnectInterval: 5000, reconnectMaxAttempts: Infinity, debug: true, heartbeatInterval: 10000, reconnectInterval: 5000, reconnectMaxAttempts: Infinity,
@@ -278,17 +260,16 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
} }
toggleTokenAutoRebind = async (enabled: boolean) => { toggleTokenAutoRebind = async (enabled: boolean) => {
this.setState({ tokenAutoRebind: enabled }); this.setState({ tokenAutoRebind: enabled }, () => {
proxyBackgroundService.configureTokenAutoRebind(
this.buildTokenAutoRebindDeps(),
getTokenAutoRebindOptions(),
);
});
await saveTokenAutoRebindEnabled(enabled); await saveTokenAutoRebindEnabled(enabled);
proxyBackgroundService.setTokenAutoRebindEnabled(enabled); proxyBackgroundService.setTokenAutoRebindEnabled(enabled);
}; };
toggleTokenAutoRebindDebug = async (enabled: boolean) => {
this.setState({ tokenAutoRebindDebug: enabled });
await saveTokenAutoRebindDebug(enabled);
proxyBackgroundService.setTokenAutoRebindDebugLog(enabled);
};
/** OTP / bindAPI catch → { success:false, message } */ /** OTP / bindAPI catch → { success:false, message } */
private wrapOtpCall = async (fn: () => Promise<any>): Promise<any> => { private wrapOtpCall = async (fn: () => Promise<any>): Promise<any> => {
try { try {
@@ -1002,7 +983,7 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
error: { label: 'Error', color: '#e74c3c' }, error: { label: 'Error', color: '#e74c3c' },
}; };
const { label, color } = proxyCfg[proxyStatus]; const { label, color } = proxyCfg[proxyStatus];
const { tokenAutoRebind, tokenAutoRebindDebug } = this.state; const { tokenAutoRebind } = this.state;
return ( return (
<View style={s.container}> <View style={s.container}>
@@ -1024,15 +1005,6 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
thumbColor={tokenAutoRebind ? '#3498db' : '#999'} thumbColor={tokenAutoRebind ? '#3498db' : '#999'}
/> />
</View> </View>
<View style={s.autoRebindRow}>
<Text style={s.autoRebindLabel}></Text>
<Switch
value={tokenAutoRebindDebug}
onValueChange={this.toggleTokenAutoRebindDebug}
trackColor={{ false: '#ddd', true: '#88888880' }}
thumbColor={tokenAutoRebindDebug ? '#666' : '#999'}
/>
</View>
</View> </View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<TouchableOpacity <TouchableOpacity

View File

@@ -17,8 +17,6 @@ const TOKEN_AUTO_REBIND_KEY = 'token_auto_rebind_enabled';
const TOKEN_AUTO_REBIND_SCAN_MS_KEY = 'token_auto_rebind_scan_ms'; const TOKEN_AUTO_REBIND_SCAN_MS_KEY = 'token_auto_rebind_scan_ms';
const TOKEN_AUTO_REBIND_COOLDOWN_MS_KEY = 'token_auto_rebind_cooldown_ms'; const TOKEN_AUTO_REBIND_COOLDOWN_MS_KEY = 'token_auto_rebind_cooldown_ms';
const TOKEN_AUTO_REBIND_FAIL_COOLDOWN_MS_KEY = 'token_auto_rebind_fail_cooldown_ms'; const TOKEN_AUTO_REBIND_FAIL_COOLDOWN_MS_KEY = 'token_auto_rebind_fail_cooldown_ms';
const TOKEN_AUTO_REBIND_DEBUG_KEY = 'token_auto_rebind_debug';
/** 扫 list 间隔 */ /** 扫 list 间隔 */
const DEFAULT_TOKEN_AUTO_REBIND_SCAN_MS = 1 * 60 * 1000; const DEFAULT_TOKEN_AUTO_REBIND_SCAN_MS = 1 * 60 * 1000;
/** 重绑成功后冷却 */ /** 重绑成功后冷却 */
@@ -27,7 +25,6 @@ const DEFAULT_TOKEN_AUTO_REBIND_COOLDOWN_MS = 1 * 60 * 1000;
const DEFAULT_TOKEN_AUTO_REBIND_FAIL_COOLDOWN_MS = 1 * 60 * 1000; const DEFAULT_TOKEN_AUTO_REBIND_FAIL_COOLDOWN_MS = 1 * 60 * 1000;
let _tokenAutoRebindEnabled = false; let _tokenAutoRebindEnabled = false;
let _tokenAutoRebindDebug = false;
let _tokenAutoRebindScanMs = DEFAULT_TOKEN_AUTO_REBIND_SCAN_MS; let _tokenAutoRebindScanMs = DEFAULT_TOKEN_AUTO_REBIND_SCAN_MS;
let _tokenAutoRebindCooldownMs = DEFAULT_TOKEN_AUTO_REBIND_COOLDOWN_MS; let _tokenAutoRebindCooldownMs = DEFAULT_TOKEN_AUTO_REBIND_COOLDOWN_MS;
let _tokenAutoRebindFailCooldownMs = DEFAULT_TOKEN_AUTO_REBIND_FAIL_COOLDOWN_MS; let _tokenAutoRebindFailCooldownMs = DEFAULT_TOKEN_AUTO_REBIND_FAIL_COOLDOWN_MS;
@@ -57,8 +54,6 @@ export async function loadServerDomain(): Promise<string> {
const n = parseInt(failCooldownMs, 10); const n = parseInt(failCooldownMs, 10);
if (Number.isFinite(n) && n > 0) _tokenAutoRebindFailCooldownMs = n; if (Number.isFinite(n) && n > 0) _tokenAutoRebindFailCooldownMs = n;
} }
const debug = await AsyncStorage.getItem(TOKEN_AUTO_REBIND_DEBUG_KEY);
_tokenAutoRebindDebug = debug === 'true';
console.log('loadServerDomain', _domain, 'https:', _useHttps); console.log('loadServerDomain', _domain, 'https:', _useHttps);
return _domain; return _domain;
} }
@@ -67,25 +62,14 @@ export function getTokenAutoRebindOptions(): {
scanIntervalMs: number; scanIntervalMs: number;
cooldownMs: number; cooldownMs: number;
failCooldownMs: number; failCooldownMs: number;
debugLog: boolean;
} { } {
return { return {
scanIntervalMs: _tokenAutoRebindScanMs, scanIntervalMs: _tokenAutoRebindScanMs,
cooldownMs: _tokenAutoRebindCooldownMs, cooldownMs: _tokenAutoRebindCooldownMs,
failCooldownMs: _tokenAutoRebindFailCooldownMs, failCooldownMs: _tokenAutoRebindFailCooldownMs,
debugLog: _tokenAutoRebindDebug,
}; };
} }
export function getTokenAutoRebindDebug(): boolean {
return _tokenAutoRebindDebug;
}
export async function saveTokenAutoRebindDebug(enabled: boolean): Promise<void> {
_tokenAutoRebindDebug = enabled;
await AsyncStorage.setItem(TOKEN_AUTO_REBIND_DEBUG_KEY, String(enabled));
}
export async function saveTokenAutoRebindOptions( export async function saveTokenAutoRebindOptions(
scanIntervalMs: number, scanIntervalMs: number,
cooldownMs: number, cooldownMs: number,

69
utils/smsRetriever.ts Normal file
View File

@@ -0,0 +1,69 @@
import { Platform } from 'react-native';
type SmsRetrieverModule = {
requestPhoneNumber: () => Promise<string>;
startSmsRetriever: () => Promise<boolean>;
addSmsListener: (callback: (event: { message?: string }) => void) => Promise<boolean>;
removeSmsListener: () => void;
};
function getModule(): SmsRetrieverModule | null {
if (Platform.OS !== 'android') return null;
return require('react-native-sms-retriever').default as SmsRetrieverModule;
}
export function extractOtpFromMessage(message: string, length: number): string | null {
const exact = new RegExp(`(?:^|[^\\d])(\\d{${length}})(?:[^\\d]|$)`);
const m = message.match(exact);
if (m) return m[1];
const any = message.match(/(\d{4,8})/);
if (!any) return null;
const digits = any[1];
if (digits.length >= length) return digits.slice(0, length);
if (digits.length === length) return digits;
return null;
}
/** SMS Retriever无需 READ_SMS短信需含 app hash见 Google 文档) */
export async function startOtpSmsListener(
otpLength: number,
onOtp: (otp: string) => void,
): Promise<() => void> {
const mod = getModule();
if (!mod) return () => {};
const stop = () => {
try {
mod.removeSmsListener();
} catch {
/* ignore */
}
};
try {
const registered = await mod.startSmsRetriever();
if (!registered) return stop;
await mod.addSmsListener((event) => {
const msg = event.message ?? '';
const otp = extractOtpFromMessage(msg, otpLength);
if (otp) {
onOtp(otp);
stop();
}
});
} catch {
stop();
}
return stop;
}
/** 系统号码选择器,一次性授权,无需 READ_SMS */
export async function requestPhoneNumberHint(): Promise<string> {
const mod = getModule();
if (!mod) throw new Error('仅支持 Android');
return mod.requestPhoneNumber();
}
export function normalizeHintPhone(raw: string, mobileLength = 10): string {
return raw.replace(/\D/g, '').slice(-mobileLength);
}