Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 104 additions & 37 deletions app/(tabs)/(home)/scanning.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useAuth } from '@clerk/expo';
import LottieView from 'lottie-react-native';
import { useIsFocused } from '@react-navigation/native';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { BackHandler, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
Expand All @@ -12,7 +13,7 @@ import { AppIcon } from '@/components/ui/app-icon';
import { useGuardedPress } from '@/utils/press-guard';

const POLLING_INTERVAL_MS = 2_000;
const MAX_POLLING_MS = 30_000;
const SCAN_SCREEN_TIMEOUT_MS = 20_000;
const SHORT_SCREEN_HEIGHT = 760;
const VERY_SHORT_SCREEN_HEIGHT = 700;
const DEFAULT_ANIMATION_SIZE = 280;
Expand All @@ -21,13 +22,16 @@ const VERY_SHORT_ANIMATION_SIZE = 188;

export default function ScanningScreen() {
const { getToken, isLoaded, isSignedIn } = useAuth();
const isFocused = useIsFocused();
const { url: urlParam } = useLocalSearchParams<{ url?: string | string[] }>();
const url = getUrlParam(urlParam);
const { height: windowHeight } = useWindowDimensions();
const tabBarHeight = useBottomTabBarHeight();
const [errorMessage, setErrorMessage] = useState('');
const [retryKey, setRetryKey] = useState(0);
const getTokenRef = useRef(getToken);
const currentAbortControllerRef = useRef<AbortController | null>(null);
const hasNavigatedRef = useRef(false);
const isShortScreen = windowHeight <= SHORT_SCREEN_HEIGHT;
const isVeryShortScreen = windowHeight <= VERY_SHORT_SCREEN_HEIGHT;
const animationSize = isVeryShortScreen
Expand All @@ -42,44 +46,79 @@ export default function ScanningScreen() {

useEffect(() => {
const abortController = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let timedOut = false;
let screenTimeoutId: ReturnType<typeof setTimeout> | null = null;
let isActive = true;
let didTimeout = false;
let hasNavigated = false;

const clearScreenTimeout = () => {
if (screenTimeoutId) {
clearTimeout(screenTimeoutId);
screenTimeoutId = null;
}
};

const handleScreenTimeout = () => {
if (!isActive || didTimeout || hasNavigated) {
return;
}

didTimeout = true;
abortController.abort();
setErrorMessage(getAnalysisErrorMessage(new Error('TIMEOUT')));
};

currentAbortControllerRef.current = abortController;
hasNavigatedRef.current = false;

if (!isLoaded) {
return () => abortController.abort();
return () => {
isActive = false;
if (currentAbortControllerRef.current === abortController) {
currentAbortControllerRef.current = null;
}
abortController.abort();
};
}

if (!isSignedIn) {
setErrorMessage('로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.');
return () => abortController.abort();
return () => {
isActive = false;
if (currentAbortControllerRef.current === abortController) {
currentAbortControllerRef.current = null;
}
abortController.abort();
};
}

if (!url) {
setErrorMessage('검사할 URL을 찾을 수 없습니다. 링크를 다시 입력해주세요.');
return () => abortController.abort();
return () => {
isActive = false;
if (currentAbortControllerRef.current === abortController) {
currentAbortControllerRef.current = null;
}
abortController.abort();
};
}

setErrorMessage('');

const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
timedOut = true;
abortController.abort();
reject(new Error('TIMEOUT'));
}, MAX_POLLING_MS);
});

Promise.race([
runAnalysisPolling({
getToken: () => getTokenRef.current(),
url,
signal: abortController.signal,
}),
timeoutPromise,
])
screenTimeoutId = setTimeout(handleScreenTimeout, SCAN_SCREEN_TIMEOUT_MS);

runAnalysisPolling({
getToken: () => getTokenRef.current(),
url,
signal: abortController.signal,
canNavigate: () => isActive && !didTimeout && !hasNavigated,
onNavigate: () => {
hasNavigated = true;
hasNavigatedRef.current = true;
clearScreenTimeout();
},
})
.catch((error) => {
if (timedOut) {
setErrorMessage(getAnalysisErrorMessage(new Error('TIMEOUT')));
if (!isActive || hasNavigated || didTimeout) {
return;
}

Expand All @@ -90,25 +129,42 @@ export default function ScanningScreen() {
setErrorMessage(getAnalysisErrorMessage(error));
})
.finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
clearScreenTimeout();
});

return () => {
if (timeoutId) {
clearTimeout(timeoutId);
isActive = false;
clearScreenTimeout();
if (currentAbortControllerRef.current === abortController) {
currentAbortControllerRef.current = null;
}

abortController.abort();
};
}, [isLoaded, isSignedIn, retryKey, url]);

const hasError = errorMessage.length > 0;
const isScanning = !hasError;
const isScanningAnimationVisible = isFocused && isScanning;
const guardedRetry = useGuardedPress(() => setRetryKey((key) => key + 1));
const guardedBack = useGuardedPress(() => router.back());

useEffect(() => {
if (!isScanningAnimationVisible) {
return undefined;
}

const animationTimeoutId = setTimeout(() => {
if (hasNavigatedRef.current) {
return;
}

currentAbortControllerRef.current?.abort();
setErrorMessage(getAnalysisErrorMessage(new Error('TIMEOUT')));
}, SCAN_SCREEN_TIMEOUT_MS);

return () => clearTimeout(animationTimeoutId);
}, [isScanningAnimationVisible, retryKey, url]);

useEffect(() => {
if (!isScanning) {
return undefined;
Expand Down Expand Up @@ -164,8 +220,8 @@ export default function ScanningScreen() {
>
<LottieView
source={require('@/assets/animations/scanning.json')}
autoPlay={!hasError}
loop={!hasError}
autoPlay={isScanningAnimationVisible}
loop={isScanningAnimationVisible}
style={[styles.animation, { width: animationSize, height: animationSize }]}
/>
<View style={styles.dotsOverlay}>
Expand Down Expand Up @@ -218,16 +274,20 @@ async function runAnalysisPolling({
getToken,
url,
signal,
canNavigate,
onNavigate,
}: {
getToken: () => Promise<string | null>;
url: string;
signal: AbortSignal;
canNavigate: () => boolean;
onNavigate: () => void;
}) {
const deadline = Date.now() + MAX_POLLING_MS;
const deadline = Date.now() + SCAN_SCREEN_TIMEOUT_MS;
let analysis = await requestAnalysis(getToken, url, { signal });

while (!signal.aborted) {
const handled = handleAnalysisResult(analysis, url, signal);
const handled = handleAnalysisResult(analysis, url, signal, canNavigate, onNavigate);

if (handled) {
return;
Expand All @@ -244,7 +304,13 @@ async function runAnalysisPolling({
}
}

function handleAnalysisResult(analysis: AnalysisResponse, fallbackUrl: string, signal: AbortSignal) {
function handleAnalysisResult(
analysis: AnalysisResponse,
fallbackUrl: string,
signal: AbortSignal,
canNavigate: () => boolean,
onNavigate: () => void,
) {
if (analysis.status === 'queued') {
return false;
}
Expand All @@ -257,7 +323,8 @@ function handleAnalysisResult(analysis: AnalysisResponse, fallbackUrl: string, s
throw new Error('MISSING_VERDICT');
}

if (!signal.aborted) {
if (!signal.aborted && canNavigate()) {
onNavigate();
router.replace({
pathname: getResultPath(analysis.verdict),
params: {
Expand Down
Loading