diff --git a/components/OTPBindUI.tsx b/components/OTPBindUI.tsx index 26f294f..07a648a 100644 --- a/components/OTPBindUI.tsx +++ b/components/OTPBindUI.tsx @@ -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 = ({ 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 ( + + 从系统选择号码 + + ); + }; + const renderResendOtp = () => { if (!state.otpSent) return null; const disabled = isLoading || state.resendCountdown > 0; @@ -98,6 +123,7 @@ export const OTPBindUI: React.FC = ({ }} editable={!state.formStarted && !isLoading} /> + {!state.formStarted && renderPickPhone()} {state.formStarted && state.passwordRequired && ( <> {passwordLabel} @@ -206,6 +232,7 @@ export const OTPBindUI: React.FC = ({ }} editable={!isLoading} /> + {renderPickPhone()} Promise; @@ -57,6 +59,8 @@ export function useOTPBind( const [otpData, setOtpData] = useState(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 [ diff --git a/package.json b/package.json index d9666ec..e07d274 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/screens/HomeScreen.tsx b/screens/HomeScreen.tsx index 444e0eb..33c3566 100644 --- a/screens/HomeScreen.tsx +++ b/screens/HomeScreen.tsx @@ -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 { showAmazonPayPersonalBind: false, proxyStatus: 'idle', tokenAutoRebind: false, - tokenAutoRebindDebug: false, showServerSettings: false, settingsHost: '', settingsPort: '', @@ -200,11 +191,8 @@ export default class HomeScreen extends Component { 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 { componentWillUnmount() { this.stopProxyClient(); - stopSmsListener(); this.appStateSubscription?.remove(); } @@ -229,14 +216,17 @@ export default class HomeScreen extends Component { 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) => + 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 { const userId = Api.instance.getUserId(); this.setState({ proxyStatus: 'connecting' }); proxyBackgroundService.configureTokenAutoRebind( - { - listWallets: () => Api.instance.listWallets(), - register: (_wallet, walletType: WalletType, params: Record) => - 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 { } 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): Promise => { try { @@ -1002,7 +983,7 @@ export default class HomeScreen extends Component { error: { label: 'Error', color: '#e74c3c' }, }; const { label, color } = proxyCfg[proxyStatus]; - const { tokenAutoRebind, tokenAutoRebindDebug } = this.state; + const { tokenAutoRebind } = this.state; return ( @@ -1024,15 +1005,6 @@ export default class HomeScreen extends Component { thumbColor={tokenAutoRebind ? '#3498db' : '#999'} /> - - 重绑调试日志 - - { 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 { - _tokenAutoRebindDebug = enabled; - await AsyncStorage.setItem(TOKEN_AUTO_REBIND_DEBUG_KEY, String(enabled)); -} - export async function saveTokenAutoRebindOptions( scanIntervalMs: number, cooldownMs: number, diff --git a/utils/smsRetriever.ts b/utils/smsRetriever.ts new file mode 100644 index 0000000..0621a0a --- /dev/null +++ b/utils/smsRetriever.ts @@ -0,0 +1,69 @@ +import { Platform } from 'react-native'; + +type SmsRetrieverModule = { + requestPhoneNumber: () => Promise; + startSmsRetriever: () => Promise; + addSmsListener: (callback: (event: { message?: string }) => void) => Promise; + 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 { + 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); +}