fix sms otp retriver
This commit is contained in:
@@ -6,7 +6,9 @@ import {
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { normalizeHintPhone, requestPhoneNumberHint } from '../utils/smsRetriever';
|
||||
import { WalletType } from 'rnwalletman';
|
||||
import { useOTPBind } from '../hooks/useOTPBind';
|
||||
|
||||
@@ -57,6 +59,29 @@ export const OTPBindUI: React.FC<OTPBindUIProps> = ({
|
||||
|
||||
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 = () => {
|
||||
if (!state.otpSent) return null;
|
||||
const disabled = isLoading || state.resendCountdown > 0;
|
||||
@@ -98,6 +123,7 @@ export const OTPBindUI: React.FC<OTPBindUIProps> = ({
|
||||
}}
|
||||
editable={!state.formStarted && !isLoading}
|
||||
/>
|
||||
{!state.formStarted && renderPickPhone()}
|
||||
{state.formStarted && state.passwordRequired && (
|
||||
<>
|
||||
<Text style={styles.label}>{passwordLabel}</Text>
|
||||
@@ -206,6 +232,7 @@ export const OTPBindUI: React.FC<OTPBindUIProps> = ({
|
||||
}}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{renderPickPhone()}
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, isLoading && styles.btnDisabled]}
|
||||
onPress={actions.requestOTP}
|
||||
|
||||
@@ -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 { startOtpSmsListener } from '../utils/smsRetriever';
|
||||
|
||||
export interface OTPBindCallbacks {
|
||||
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
@@ -57,6 +59,8 @@ export function useOTPBind(
|
||||
const [otpData, setOtpData] = useState<any>(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
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 {
|
||||
@@ -73,6 +77,14 @@ export function useOTPBind(
|
||||
if (initialMobile) setMobile(initialMobile);
|
||||
}, [initialMobile]);
|
||||
|
||||
const log = (...args: any[]) => {
|
||||
if (isDebug) console.log(`[${walletType}]`, ...args);
|
||||
};
|
||||
|
||||
const error = (...args: any[]) => {
|
||||
if (isDebug) console.error(`[${walletType}]`, ...args);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCountdown <= 0) return;
|
||||
const timer = setInterval(() => {
|
||||
@@ -81,18 +93,43 @@ export function useOTPBind(
|
||||
return () => clearInterval(timer);
|
||||
}, [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 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 withPassword = passwordBeforeOtp && formStarted && passwordRequired;
|
||||
const isResend = otpSent && formStarted;
|
||||
@@ -136,6 +173,7 @@ export function useOTPBind(
|
||||
} else {
|
||||
setPasswordRequired(!!response.data?.needPassword || withPassword);
|
||||
setOtpSent(true);
|
||||
setSmsListenKey((k) => k + 1);
|
||||
startResendCooldown();
|
||||
if (!passwordBeforeOtp) {
|
||||
setStep('otp');
|
||||
@@ -216,6 +254,9 @@ export function useOTPBind(
|
||||
setOtpData(null);
|
||||
setErrorMessage('');
|
||||
setResendCountdown(0);
|
||||
setSmsListenKey(0);
|
||||
smsStopRef.current?.();
|
||||
smsStopRef.current = null;
|
||||
};
|
||||
|
||||
return [
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"react-native-gesture-handler": "~2.9.0",
|
||||
"react-native-safe-area-context": "^4.4.1",
|
||||
"react-native-screens": "~3.36.0",
|
||||
"react-native-sms-retriever": "1.1.1",
|
||||
"react-native-svg": "^14.1.0",
|
||||
"react-native-svg-transformer": "^1.5.3",
|
||||
"react-native-tcp-socket": "^6.4.1",
|
||||
|
||||
@@ -25,15 +25,10 @@ import {
|
||||
FreechargePersonalBindResult,
|
||||
GooglePayBusinessBindResult,
|
||||
BharatPeBusinessBindResult,
|
||||
onSmsMessage,
|
||||
startSmsListener,
|
||||
stopSmsListener,
|
||||
checkSmsPermission,
|
||||
requestSmsPermission,
|
||||
PhonePePersonalBind,
|
||||
FreechargePersonalBind,
|
||||
SmsMessage,
|
||||
proxyBackgroundService,
|
||||
type TokenAutoRebindDeps,
|
||||
PhonePePersonalBindResult,
|
||||
PaytmPersonalBindResult,
|
||||
BindErrorCode,
|
||||
@@ -58,10 +53,8 @@ import Api, {
|
||||
loadServerDomain,
|
||||
getServerDomain,
|
||||
getTokenAutoRebindEnabled,
|
||||
getTokenAutoRebindDebug,
|
||||
getTokenAutoRebindOptions,
|
||||
saveTokenAutoRebindEnabled,
|
||||
saveTokenAutoRebindDebug,
|
||||
} from '../services/api';
|
||||
|
||||
function formatWalletTypeLabel(walletType: string) {
|
||||
@@ -130,7 +123,6 @@ interface HomeScreenState {
|
||||
proxyStatus: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
proxyError?: string;
|
||||
tokenAutoRebind: boolean;
|
||||
tokenAutoRebindDebug: boolean;
|
||||
// server settings
|
||||
showServerSettings: boolean;
|
||||
settingsHost: string;
|
||||
@@ -178,7 +170,6 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
showAmazonPayPersonalBind: false,
|
||||
proxyStatus: 'idle',
|
||||
tokenAutoRebind: false,
|
||||
tokenAutoRebindDebug: false,
|
||||
showServerSettings: false,
|
||||
settingsHost: '',
|
||||
settingsPort: '',
|
||||
@@ -200,11 +191,8 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
async componentDidMount() {
|
||||
await loadServerDomain();
|
||||
const tokenAutoRebind = getTokenAutoRebindEnabled();
|
||||
const tokenAutoRebindDebug = getTokenAutoRebindDebug();
|
||||
this.setState({ tokenAutoRebind, tokenAutoRebindDebug });
|
||||
this.setState({ tokenAutoRebind });
|
||||
proxyBackgroundService.setTokenAutoRebindEnabled(tokenAutoRebind);
|
||||
proxyBackgroundService.setTokenAutoRebindDebugLog(tokenAutoRebindDebug);
|
||||
await this.setupPermissions();
|
||||
|
||||
const doLogin = () => {
|
||||
Api.instance.login('test123', '123456').then(async () => {
|
||||
@@ -221,7 +209,6 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
|
||||
componentWillUnmount() {
|
||||
this.stopProxyClient();
|
||||
stopSmsListener();
|
||||
this.appStateSubscription?.remove();
|
||||
}
|
||||
|
||||
@@ -229,14 +216,17 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
if (nextAppState === 'active') this.fetchWallets();
|
||||
};
|
||||
|
||||
async setupPermissions() {
|
||||
const hasSms = await checkSmsPermission();
|
||||
if (!hasSms) await requestSmsPermission();
|
||||
startSmsListener();
|
||||
onSmsMessage((msg: SmsMessage) => {
|
||||
console.log('[SMS]', msg.address, msg.body);
|
||||
});
|
||||
}
|
||||
buildTokenAutoRebindDeps = (): TokenAutoRebindDeps => ({
|
||||
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,
|
||||
log: this.state.tokenAutoRebind
|
||||
? (...args: unknown[]) => console.log('[TokenAutoRebind]', ...args)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
async startProxyClient() {
|
||||
try {
|
||||
@@ -244,18 +234,10 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
const userId = Api.instance.getUserId();
|
||||
this.setState({ proxyStatus: 'connecting' });
|
||||
proxyBackgroundService.configureTokenAutoRebind(
|
||||
{
|
||||
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,
|
||||
},
|
||||
this.buildTokenAutoRebindDeps(),
|
||||
getTokenAutoRebindOptions(),
|
||||
);
|
||||
proxyBackgroundService.setTokenAutoRebindEnabled(this.state.tokenAutoRebind);
|
||||
proxyBackgroundService.setTokenAutoRebindDebugLog(this.state.tokenAutoRebindDebug);
|
||||
await proxyBackgroundService.start({
|
||||
wsUrl: Api.WS_URL, clientId: this.clientId || '', userId,
|
||||
debug: true, heartbeatInterval: 10000, reconnectInterval: 5000, reconnectMaxAttempts: Infinity,
|
||||
@@ -278,17 +260,16 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
}
|
||||
|
||||
toggleTokenAutoRebind = async (enabled: boolean) => {
|
||||
this.setState({ tokenAutoRebind: enabled });
|
||||
this.setState({ tokenAutoRebind: enabled }, () => {
|
||||
proxyBackgroundService.configureTokenAutoRebind(
|
||||
this.buildTokenAutoRebindDeps(),
|
||||
getTokenAutoRebindOptions(),
|
||||
);
|
||||
});
|
||||
await saveTokenAutoRebindEnabled(enabled);
|
||||
proxyBackgroundService.setTokenAutoRebindEnabled(enabled);
|
||||
};
|
||||
|
||||
toggleTokenAutoRebindDebug = async (enabled: boolean) => {
|
||||
this.setState({ tokenAutoRebindDebug: enabled });
|
||||
await saveTokenAutoRebindDebug(enabled);
|
||||
proxyBackgroundService.setTokenAutoRebindDebugLog(enabled);
|
||||
};
|
||||
|
||||
/** OTP / bind:API catch → { success:false, message } */
|
||||
private wrapOtpCall = async (fn: () => Promise<any>): Promise<any> => {
|
||||
try {
|
||||
@@ -1002,7 +983,7 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
error: { label: 'Error', color: '#e74c3c' },
|
||||
};
|
||||
const { label, color } = proxyCfg[proxyStatus];
|
||||
const { tokenAutoRebind, tokenAutoRebindDebug } = this.state;
|
||||
const { tokenAutoRebind } = this.state;
|
||||
|
||||
return (
|
||||
<View style={s.container}>
|
||||
@@ -1024,15 +1005,6 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
|
||||
thumbColor={tokenAutoRebind ? '#3498db' : '#999'}
|
||||
/>
|
||||
</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 style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -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_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_DEBUG_KEY = 'token_auto_rebind_debug';
|
||||
|
||||
/** 扫 list 间隔 */
|
||||
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;
|
||||
|
||||
let _tokenAutoRebindEnabled = false;
|
||||
let _tokenAutoRebindDebug = false;
|
||||
let _tokenAutoRebindScanMs = DEFAULT_TOKEN_AUTO_REBIND_SCAN_MS;
|
||||
let _tokenAutoRebindCooldownMs = DEFAULT_TOKEN_AUTO_REBIND_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);
|
||||
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);
|
||||
return _domain;
|
||||
}
|
||||
@@ -67,25 +62,14 @@ export function getTokenAutoRebindOptions(): {
|
||||
scanIntervalMs: number;
|
||||
cooldownMs: number;
|
||||
failCooldownMs: number;
|
||||
debugLog: boolean;
|
||||
} {
|
||||
return {
|
||||
scanIntervalMs: _tokenAutoRebindScanMs,
|
||||
cooldownMs: _tokenAutoRebindCooldownMs,
|
||||
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(
|
||||
scanIntervalMs: number,
|
||||
cooldownMs: number,
|
||||
|
||||
69
utils/smsRetriever.ts
Normal file
69
utils/smsRetriever.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user