// noinspection JSIgnoredPromiseFromCall,JSVoidFunctionReturnValueUsed import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Image, ImageSourcePropType, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Slider } from '../react-native-awesome-slider/src-change'; import { useSharedValue } from 'react-native-reanimated'; import { GestureHandlerRootView, PanGestureHandlerEventPayload } from 'react-native-gesture-handler'; //region import Crypto // import * as Crypto from 'expo-crypto'; import 'react-native-get-random-values'; import CryptoJS from 'crypto-js'; const Crypto: any = (global as any).crypto; //endregion /** * AES 加密 * @param plaintext * @param key 密钥,必须是 16、24 或 32 位 */ const encrypt = (plaintext: string, key: string) => { return CryptoJS.AES.encrypt(plaintext, key).toString(); }; const decrypt = (ciphertext: string, key: string) => { return CryptoJS.AES.decrypt(ciphertext, key).toString(CryptoJS.enc.Utf8); }; export const AESUtil = { encrypt, decrypt, }; /** * 返回 [minValue, maxValue] 的随机数, 而不是百分比 * @param minValue * @param maxValue */ export const getRandomValueBetween = (minValue: number, maxValue: number): number => { const randomPercent = (Crypto.getRandomValues(new Uint8Array(1))[0] as number) / 256.0; let randomValue = minValue + (maxValue - minValue) * randomPercent; randomValue = Math.min(Math.max(minValue, randomValue), maxValue); return randomValue; }; export const sum = (x: number, y: number) => x + y; export const square = (x: number) => x * x; export interface SliderAllStatus { default: SliderItemStatus; moving: SliderItemStatus; success: SliderItemStatus; failure: SliderItemStatus; } export interface SliderItemStatus { backgroundColor: string; borderColor: string; sliderText?: string; thumbBackgroundColor: string; thumbImageSource: ImageSourcePropType; } export interface BubbleSource { behind: ImageSourcePropType; above: ImageSourcePropType; } export type LocalBubbleBehindType = 'always' | 'never' | 'showAfterTouchBegin'; export interface CaptchaProps { /** * 禁用滑块滑动, default false */ disableSlider?: boolean; localBubbleBehindType?: LocalBubbleBehindType; /** * 校验的误差范围内算成功 default 3 */ maxOffset?: number; /** * 滑块尺寸 default 38 */ thumbSize?: number; /** * 弹窗尺寸 default 49 */ bubbleSize?: number; /** * 底图宽度 default 292 */ imageWidth?: number; /** * 底图高度 default 145 */ imageHeight?: number; verifyLocal?: ( sliderCurrentProgress: number, randomBubbleXProgress: number, maxOffset: number, events: PanGestureHandlerEventPayload[], touchTime: number | undefined ) => boolean; verifyRemote?: ( isVerifyLocalSuccess: boolean, slideValue: number, maxOffset: number, events: PanGestureHandlerEventPayload[], touchTime: number | undefined ) => Promise; /** * sliding start */ onSlidingStart?: () => void; onSlidingTouchBegin?: () => void; /** * on refresh clicked */ onRefreshClicked?: () => void; onSlidingComplete?: () => void; /** * replace the all background image sources */ imageBackgroundSources?: ImageSourcePropType[]; /** * replace the all bubble image sources */ imageBubbleSources?: BubbleSource[]; /** * replace the refresh image sources */ imageRefreshSource?: ImageSourcePropType; /** * 向右滑动滑块解锁拼图 */ defaultSliderText?: string; } export default function Captcha({ disableSlider, localBubbleBehindType = 'showAfterTouchBegin', maxOffset = 3, thumbSize = 38, bubbleSize = 49, imageWidth = 292, imageHeight = 145, verifyLocal, verifyRemote, onSlidingStart, onSlidingTouchBegin, onSlidingComplete, imageBackgroundSources, imageBubbleSources, onRefreshClicked, imageRefreshSource, defaultSliderText = '向右滑动滑块解锁拼图', }: CaptchaProps) { const sliderMinProgress = useSharedValue(0); const sliderMaxProgress = useSharedValue(100); const sliderCurrentProgress = useSharedValue(0); const maxXProgress = 100; const imageContainerBubbleCanMoveMaxWidth = imageWidth - bubbleSize; const minXProgress = (bubbleSize / 3 / imageContainerBubbleCanMoveMaxWidth) * 100; // bubble 三分之一 const getRandomXProgress = useCallback(() => getRandomValueBetween(minXProgress, maxXProgress), [minXProgress]); const getRandomYValue = useCallback( () => getRandomValueBetween(0, imageHeight - bubbleSize), [bubbleSize, imageHeight] ); const sliderAllStatus = useRef({ default: { backgroundColor: '#f0f0f0', borderColor: '#ffffff', sliderText: defaultSliderText, thumbBackgroundColor: '#ffffff', thumbImageSource: require('../../assets/icon-arrow-right-black.png'), }, moving: { backgroundColor: 'rgba(47, 108, 224, 0.1)', borderColor: '#2f6ce0', sliderText: undefined, thumbBackgroundColor: '#2f6ce0', thumbImageSource: require('../../assets/icon-arrow-right-white.png'), }, success: { backgroundColor: 'rgba(59, 206, 118, 0.1)', borderColor: '#3bce76', sliderText: undefined, thumbBackgroundColor: '#3bce76', thumbImageSource: require('../../assets/icon-success-white.png'), }, failure: { backgroundColor: 'rgba(242, 68, 82, 0.1)', borderColor: '#f24452', sliderText: undefined, thumbBackgroundColor: '#f24452', thumbImageSource: require('../../assets/icon-failure-white.png'), }, }); const [currentSliderItemStatus, setCurrentSliderItemStatus] = useState( sliderAllStatus.current.default ); const innerImageBackgroundSources = useRef( imageBackgroundSources?.length ? imageBackgroundSources : new Array(16).fill(0).map((_, index) => { return { uri: 'https://ess-bucket01-prod.s3.dualstack.cn-northwest-1.amazonaws.com.cn/captcha-image/image-' + index + '.png', }; }) ); const innerImageBubbleSources = useRef( imageBubbleSources?.length ? imageBubbleSources : [ { behind: require('../../assets/icon-bubble-behind-0.png'), above: require('../../assets/icon-bubble-above-0.png'), }, { behind: require('../../assets/icon-bubble-behind-1.png'), above: require('../../assets/icon-bubble-above-1.png'), }, { behind: require('../../assets/icon-bubble-behind-2.png'), above: require('../../assets/icon-bubble-above-2.png'), }, { behind: require('../../assets/icon-bubble-behind-3.png'), above: require('../../assets/icon-bubble-above-3.png'), }, ] ); const innerImageRefreshSource = useRef( imageRefreshSource ?? require('../../assets/icon-refresh.png') ); const [imageBackgroundSource, setImageBackgroundSource] = useState( innerImageBackgroundSources.current[0] as ImageSourcePropType ); const [imageBubbleBehindSource, setImageBubbleBehindSource] = useState( innerImageBubbleSources.current[0]?.behind as ImageSourcePropType ); const [imageBubbleAboveSource, setImageBubbleAboveSource] = useState( innerImageBubbleSources.current[0]?.above as ImageSourcePropType ); const [localBubbleBehindOpacity, setLocalBubbleBehindOpacity] = useState( localBubbleBehindType === 'never' || localBubbleBehindType === 'showAfterTouchBegin' ? 0 : 1 ); const [randomXProgress, setRandomXProgress] = useState(getRandomXProgress()); const [randomYValue, setRandomYValue] = useState(getRandomYValue()); const [bubbleImageBehindBottom, setBubbleImageBehindBottom] = useState(randomYValue); const [bubbleTranslateY, setBubbleTranslateY] = useState(-bubbleImageBehindBottom - 12 - bubbleSize); const justRandomX = useCallback(() => { const newRandomXValue = getRandomXProgress(); setRandomXProgress(newRandomXValue); }, [getRandomXProgress]); const justRandomXY = useCallback(() => { justRandomX(); if (localBubbleBehindType === 'showAfterTouchBegin') { setLocalBubbleBehindOpacity(0); } const newRandomYValue = getRandomYValue(); setRandomYValue(newRandomYValue); const newBubbleImageBehindBottom = newRandomYValue; setBubbleImageBehindBottom(newBubbleImageBehindBottom); setBubbleTranslateY(-newBubbleImageBehindBottom - 12 - bubbleSize); }, [justRandomX, localBubbleBehindType, getRandomYValue, bubbleSize]); const onInnerRefreshClicked = useCallback(() => { justRandomXY(); if (innerImageBackgroundSources.current?.length) { const randomIndex = Math.floor(getRandomValueBetween(0, innerImageBackgroundSources.current.length - 1)); const newSource = innerImageBackgroundSources.current[randomIndex]; if (newSource) { setImageBackgroundSource(newSource); } } if (innerImageBubbleSources.current?.length) { const randomIndex = Math.floor(getRandomValueBetween(0, innerImageBubbleSources.current.length - 1)); const newSource = innerImageBubbleSources.current[randomIndex]; if (newSource) { setImageBubbleBehindSource(newSource.behind); setImageBubbleAboveSource(newSource.above); } } sliderCurrentProgress.value = 0; setCurrentSliderItemStatus(sliderAllStatus.current.default); onRefreshClicked?.(); }, [justRandomXY, onRefreshClicked, sliderCurrentProgress]); const innerVerifyLocal = ( sliderValue: number, events: PanGestureHandlerEventPayload[], touchTime: number | undefined ): boolean => { if (verifyLocal) { return verifyLocal(sliderValue, randomXProgress, maxOffset, events, touchTime); } const yAxisArray = events.map((item) => item.y); const yAxisAverage = yAxisArray.reduce(sum) / yAxisArray.length; const yAxisDeviations = yAxisArray.map((x) => x - yAxisAverage); const yAxisStandardDeviation = Math.sqrt(yAxisDeviations.map(square).reduce(sum) / yAxisArray.length); const verifiedYAxisStandardDeviation = yAxisStandardDeviation !== 0; return ( verifiedYAxisStandardDeviation && (touchTime ?? 0) >= 300 && (randomXProgress ?? 0) >= sliderCurrentProgress.value - maxOffset && (randomXProgress ?? 0) <= sliderCurrentProgress.value + maxOffset ); }; const onInnerSlidingTouchBegin = () => { onSlidingTouchBegin?.(); }; const onInnerSlidingStart = () => { // setCurrentSliderItemStatus(sliderAllStatus.current.moving); if (localBubbleBehindType === 'showAfterTouchBegin') { justRandomX(); setLocalBubbleBehindOpacity(1); } setCurrentSliderItemStatus(sliderAllStatus.current.moving); onSlidingStart?.(); }; const handleVerify = async ( slideValue: number, events: PanGestureHandlerEventPayload[], touchTime: number | undefined ) => { if (typeof touchTime === 'undefined') { // onInnerRefreshClicked(); return; } const isVerifyLocalSuccess = innerVerifyLocal(slideValue, events, touchTime); let isVerifyFinalSuccess = isVerifyLocalSuccess; if (verifyRemote) { try { // noinspection UnnecessaryLocalVariableJS const isVerifyRemoteSuccess = await verifyRemote?.( isVerifyLocalSuccess, slideValue, maxOffset, events, touchTime ); isVerifyFinalSuccess = isVerifyRemoteSuccess; } catch (e) { console.log('> call onSlidingComplete failure', e); } } if (isVerifyFinalSuccess) { setCurrentSliderItemStatus(sliderAllStatus.current.success); } else { setCurrentSliderItemStatus(sliderAllStatus.current.failure); } // sliderCurrentProgress.value = 0; }; const onInnerSlidingComplete = ( slideValue: number, events: PanGestureHandlerEventPayload[], touchTime: number | undefined ) => { handleVerify(slideValue, events, touchTime).then(); onSlidingComplete?.(); }; useEffect(() => { onInnerRefreshClicked(); }, [onInnerRefreshClicked]); return ( {(localBubbleBehindType === 'showAfterTouchBegin' || localBubbleBehindType === 'always') && ( )} { onInnerRefreshClicked(); }} > ( )} renderBubble={() => ( )} /> ); } const styles = StyleSheet.create({ captchaRoot: { position: 'absolute', left: 0, right: 0, top: 0, zIndex: 99, width: '100%', height: '100%', backgroundColor: 'rgba(51, 51, 51, 0.5)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', }, captchaContainer: { borderRadius: 12, backgroundColor: '#fff', display: 'flex', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', paddingVertical: 20, paddingHorizontal: 14, }, captchaImageContainer: { position: 'relative', }, captchaImage: { width: '100%', height: '100%', borderTopLeftRadius: 6, borderTopRightRadius: 6, }, captchaSliderRoot: { marginTop: 12, padding: 0, margin: 0, justifyContent: 'flex-start', alignItems: 'flex-start', }, captchaSliderContainer: { padding: 0, margin: 0, borderRadius: 2, backgroundColor: '#f0f0f0', borderStyle: 'solid', borderColor: 'rgba(51, 51, 51, 0.1)', borderWidth: 1, }, thumb: { padding: 0, margin: 0, borderRadius: 2, shadowColor: 'rgba(0, 0, 0, 0.12)', shadowOffset: { width: 0, height: 0, }, shadowRadius: 4, elevation: 4, shadowOpacity: 1, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', }, bubbleContainerStyle: { backgroundColor: 'transparent', margin: 0, alignItems: 'center', }, bubbleImageAbove: { padding: 0, margin: 0, }, thumbImage: { width: 24, height: 24, }, bubbleImageBehind: { position: 'absolute', }, bubbleImageRefreshContainer: { position: 'absolute', right: 0, top: 0, zIndex: 9, alignItems: 'center', justifyContent: 'center', padding: 7, }, bubbleImageRefreshImage: { width: 16, height: 16, }, });