From 562e296152d8d4a2ba210b6472939f67142f4fce Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Sat, 26 Sep 2026 04:17:35 +0800 Subject: [PATCH 1/2] feat: classify network errors and show specific messages Timeout, Cloudflare 403, 404, 5xx, offline, and "internet works but the site refuses/drops us" (told apart by a connectivity probe) now render with a retry button on the browse/tag/user/watch screens, instead of a generic error that looked like an empty search. --- src/app/(app)/index.tsx | 32 ++++--- src/app/tag/[name].tsx | 18 +++- src/app/user/[id].tsx | 7 +- src/app/watch/[id].tsx | 26 +++--- src/components/video-grid.tsx | 26 +++++- src/lib/hanime1/scraper.test.ts | 11 ++- src/lib/hanime1/scraper.ts | 39 +++++---- src/lib/network/errors.test.ts | 91 ++++++++++++++++++++ src/lib/network/errors.ts | 144 ++++++++++++++++++++++++++++++++ src/translations/en.json | 10 +++ src/translations/ja.json | 10 +++ src/translations/ko.json | 10 +++ src/translations/zh-CN.json | 10 +++ src/translations/zh.json | 10 +++ 14 files changed, 398 insertions(+), 46 deletions(-) create mode 100644 src/lib/network/errors.test.ts create mode 100644 src/lib/network/errors.ts diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index cc8482a..064fd18 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -11,6 +11,7 @@ import { import { VideoGrid } from '@/components/video-grid'; import { GENRES, SORTS } from '@/lib/hanime1/types'; import { useTranslate } from '@/lib/i18n/utils'; +import { networkErrorMessage } from '@/lib/network/errors'; import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import { useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -55,16 +56,25 @@ export default function BrowseScreen() { // Default browse = genre only (no query, no sort), matching the site. Query is // added only when typing; sort/tags/broad only when set in the filter sheet. - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch, isFetching } = - useSearchVideos({ - variables: { - genre, - sort: filter.sortKey ? SORTS[filter.sortKey] : undefined, - query: debounced || undefined, - tags: filter.tags.length > 0 ? filter.tags : undefined, - broad: filter.broad || undefined, - }, - }); + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + refetch, + isFetching, + isError, + error, + } = useSearchVideos({ + variables: { + genre, + sort: filter.sortKey ? SORTS[filter.sortKey] : undefined, + query: debounced || undefined, + tags: filter.tags.length > 0 ? filter.tags : undefined, + broad: filter.broad || undefined, + }, + }); useFocusEffect(useCallback(() => {}, [])); @@ -127,6 +137,8 @@ export default function BrowseScreen() { isRefreshing={isFetching && !isLoading} isFetchingNextPage={isFetchingNextPage} isLoading={isLoading} + errorText={isError ? networkErrorMessage(error, t) : undefined} + onRetry={() => refetch()} contentContainerStyle={{ paddingBottom: 24 }} /> diff --git a/src/app/tag/[name].tsx b/src/app/tag/[name].tsx index 021d22a..a32df53 100644 --- a/src/app/tag/[name].tsx +++ b/src/app/tag/[name].tsx @@ -2,6 +2,8 @@ import { useSearchVideos } from '@/api/video-queries'; import { ScreenErrorBoundary } from '@/components/error-boundary'; import { SafeAreaView } from '@/components/safe-area-view'; import { VideoGrid } from '@/components/video-grid'; +import { useTranslate } from '@/lib/i18n/utils'; +import { networkErrorMessage } from '@/lib/network/errors'; import { useLocalSearchParams } from 'expo-router'; import { useMemo } from 'react'; import { Text, View } from 'react-native'; @@ -9,14 +11,24 @@ import { Text, View } from 'react-native'; export { ScreenErrorBoundary as ErrorBoundary }; export default function TagScreen() { + const t = useTranslate(); const params = useLocalSearchParams<{ name: string; type?: string }>(); const name = params.name; // `query` tags (franchise/character, e.g. #絕區零) must be searched as free // text — the `tags[]` filter knows nothing about them. const searchTags = params.type === 'query' ? undefined : [name]; const query = params.type === 'query' ? name : undefined; - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch, isFetching } = - useSearchVideos({ variables: { tags: searchTags, query } }); + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + refetch, + isFetching, + isError, + error, + } = useSearchVideos({ variables: { tags: searchTags, query } }); const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]); @@ -36,6 +48,8 @@ export default function TagScreen() { isRefreshing={isFetching && !isLoading} isFetchingNextPage={isFetchingNextPage} isLoading={isLoading} + errorText={isError ? networkErrorMessage(error, t) : undefined} + onRetry={() => refetch()} contentContainerStyle={{ paddingBottom: 24 }} /> diff --git a/src/app/user/[id].tsx b/src/app/user/[id].tsx index f641f20..e5d623e 100644 --- a/src/app/user/[id].tsx +++ b/src/app/user/[id].tsx @@ -2,6 +2,8 @@ import { useUserVideos } from '@/api/video-queries'; import { ScreenErrorBoundary } from '@/components/error-boundary'; import { SafeAreaView } from '@/components/safe-area-view'; import { VideoGrid } from '@/components/video-grid'; +import { useTranslate } from '@/lib/i18n/utils'; +import { networkErrorMessage } from '@/lib/network/errors'; import { useLocalSearchParams } from 'expo-router'; import { useMemo } from 'react'; import { Text, View } from 'react-native'; @@ -9,8 +11,9 @@ import { Text, View } from 'react-native'; export { ScreenErrorBoundary as ErrorBoundary }; export default function UserScreen() { + const t = useTranslate(); const params = useLocalSearchParams<{ id: string; name?: string }>(); - const { data, isLoading, refetch, isFetching } = useUserVideos({ + const { data, isLoading, refetch, isFetching, isError, error } = useUserVideos({ variables: { id: params.id }, }); @@ -29,6 +32,8 @@ export default function UserScreen() { onRefresh={() => refetch()} isRefreshing={isFetching && !isLoading} isLoading={isLoading} + errorText={isError ? networkErrorMessage(error, t) : undefined} + onRetry={() => refetch()} contentContainerStyle={{ paddingBottom: 24 }} /> diff --git a/src/app/watch/[id].tsx b/src/app/watch/[id].tsx index 6ca2d05..7d19005 100644 --- a/src/app/watch/[id].tsx +++ b/src/app/watch/[id].tsx @@ -4,10 +4,11 @@ import { Icon } from '@/components/icon'; import { StyledVideoView } from '@/components/native-styled'; import { SafeAreaView } from '@/components/safe-area-view'; import { toOfflineDetail } from '@/lib/download/offline-detail'; -import { HttpStatusError, buildUrl, endpoints } from '@/lib/hanime1/scraper'; +import { buildUrl, endpoints } from '@/lib/hanime1/scraper'; import { haptic, hapticSuccess } from '@/lib/haptics'; import { usePlaybackSettings, useVideoDownload } from '@/lib/hooks'; import { useTranslate } from '@/lib/i18n/utils'; +import { AppNetworkError, networkErrorMessage } from '@/lib/network/errors'; import { useDownloadedStore, useFavoritesStore, @@ -52,8 +53,7 @@ export default function WatchScreen() { // A 404/410 watch page means the site removed the video. With a local // download the screen stays open and playable — just notify once; other // failures (timeouts, bot blocks) stay silent while offline data is shown. - const isRemoved = - isError && error instanceof HttpStatusError && (error.status === 404 || error.status === 410); + const isRemoved = isError && error instanceof AppNetworkError && error.kind === 'notFound'; const removedNotifiedFor = useRef(null); useEffect(() => { if (!isRemoved || !offlineDetail || removedNotifiedFor.current === id) return; @@ -98,11 +98,11 @@ export default function WatchScreen() { const { autoplay } = usePlaybackSettings(); - // Focus guard for the slow-network case: the detail query can resolve long - // after the user has backed out of this screen (it stays mounted in the - // stack). Firing play() then would start a player that was never attached - // to a view — or play audio underneath the previous screen — so autoplay - // must only run while this screen actually has focus. + // Focus guard: a screen stacked under another one (rapid duplicate pushes, + // author→video chains) — or one whose detail query resolves long after the + // user backed out — must never start playing. Its blur-time pause() ran + // before the stream source landed and was a no-op, so a late autoplay would + // layer audio under the focused screen. const isFocusedRef = useRef(true); // Start playback automatically when the screen is entered or the source @@ -150,9 +150,13 @@ export default function WatchScreen() { if (!data) { return ( - - - {isRemoved ? t('detail.videoRemoved') : t('common.error')} + + + {isRemoved + ? t('detail.videoRemoved') + : isError + ? networkErrorMessage(error, t) + : t('common.error')} refetch()} className="mt-3 rounded-full bg-primary px-4 py-2"> {t('common.retry')} diff --git a/src/components/video-grid.tsx b/src/components/video-grid.tsx index b0e7ae8..016547f 100644 --- a/src/components/video-grid.tsx +++ b/src/components/video-grid.tsx @@ -9,7 +9,7 @@ import { import { useTranslate } from '@/lib/i18n/utils'; import { FlashList } from '@shopify/flash-list'; import type { ReactElement } from 'react'; -import { ActivityIndicator, RefreshControl, ScrollView, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, Text, View } from 'react-native'; export type VideoGridProps = { items: TileItem[]; @@ -18,6 +18,13 @@ export type VideoGridProps = { isRefreshing?: boolean; isFetchingNextPage?: boolean; isLoading?: boolean; + /** + * Classified error message for the failed fetch. When set with no items to + * show, the grid explains the failure (network blocked, offline, …) with a + * retry button — instead of a misleading "no results". + */ + errorText?: string; + onRetry?: () => void; columns?: number; /** * Card form for every tile in the grid. All tiles share one orientation so @@ -39,6 +46,8 @@ export function VideoGrid({ isRefreshing, isFetchingNextPage, isLoading, + errorText, + onRetry, columns, orientation = 'auto', ListHeaderComponent, @@ -113,7 +122,20 @@ export function VideoGrid({ - {t('common.noResults')} + {errorText ? ( + <> + + {errorText} + + {onRetry ? ( + + {t('common.retry')} + + ) : null} + + ) : ( + {t('common.noResults')} + )} } ListFooterComponent={ diff --git a/src/lib/hanime1/scraper.test.ts b/src/lib/hanime1/scraper.test.ts index 3459703..dc0c112 100644 --- a/src/lib/hanime1/scraper.test.ts +++ b/src/lib/hanime1/scraper.test.ts @@ -130,10 +130,15 @@ describe('fetchPage', () => { globalThis.fetch = realFetch; }); - it('throws HttpStatusError carrying the status for non-2xx responses', async () => { - globalThis.fetch = (async () => ({ ok: false, status: 404 })) as unknown as typeof fetch; + it('throws AppNetworkError with kind + status for non-2xx responses', async () => { + globalThis.fetch = (async () => ({ + ok: false, + status: 404, + text: async () => '', + })) as unknown as typeof fetch; await expect(fetchPage('/watch?v=1')).rejects.toMatchObject({ - name: 'HttpStatusError', + name: 'AppNetworkError', + kind: 'notFound', status: 404, }); }); diff --git a/src/lib/hanime1/scraper.ts b/src/lib/hanime1/scraper.ts index 50e8447..f99c8e6 100644 --- a/src/lib/hanime1/scraper.ts +++ b/src/lib/hanime1/scraper.ts @@ -8,6 +8,12 @@ * named captures the way V8 does. We therefore use **positional** capture * groups (`match[1..n]`) exclusively and avoid `.groups`. */ +import { + AppNetworkError, + classifyHttpStatus, + classifyTransportError, + httpErrorDetail, +} from '@/lib/network/errors'; import { buildSearchUrl, buildUrl, endpoints } from './endpoints'; import type { ListResult, VideoDetail, VideoListItem, VideoSource, VideoTag } from './types'; @@ -15,17 +21,6 @@ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; const FETCH_TIMEOUT_MS = 15_000; -/** Non-2xx site response — `status` 404/410 marks a video as removed. */ -export class HttpStatusError extends Error { - readonly status: number; - - constructor(status: number, url: string) { - super(`Request failed (${status}): ${url}`); - this.name = 'HttpStatusError'; - this.status = status; - } -} - /** Iterate all regex matches positionally (Hermes-safe; no `.groups`). */ function forEachMatch(input: string, pattern: RegExp, cb: (m: RegExpExecArray) => void): void { pattern.lastIndex = 0; @@ -36,7 +31,11 @@ function forEachMatch(input: string, pattern: RegExp, cb: (m: RegExpExecArray) = } } -/** Fetch a page as text with a desktop UA + timeout. */ +/** + * Fetch a page as text with a desktop UA + timeout. Failures are classified + * (timeout / Cloudflare block / offline / site-unreachable / …) into + * `AppNetworkError`s the UI can render specific messages for. + */ export async function fetchPage(pathOrUrl: string): Promise { const url = pathOrUrl.startsWith('http') ? pathOrUrl : buildUrl(pathOrUrl); const controller = new AbortController(); @@ -51,14 +50,20 @@ export async function fetchPage(pathOrUrl: string): Promise { signal: controller.signal, }); if (!res.ok) { - throw new HttpStatusError(res.status, url); + const body = await res.text().catch(() => ''); + const err = new AppNetworkError(classifyHttpStatus(res.status), { + message: `Request failed (${res.status})`, + status: res.status, + url, + detail: httpErrorDetail({ status: res.status, body }), + }); + throw err; } return await res.text(); } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - throw new Error('Request timed out'); - } - throw err; + if (err instanceof AppNetworkError) throw err; + const appErr = await classifyTransportError(err, url); + throw appErr; } finally { clearTimeout(timeout); } diff --git a/src/lib/network/errors.test.ts b/src/lib/network/errors.test.ts new file mode 100644 index 0000000..3bd0e0a --- /dev/null +++ b/src/lib/network/errors.test.ts @@ -0,0 +1,91 @@ +import { + AppNetworkError, + classifyHttpStatus, + classifyTransportError, + httpErrorDetail, + isCloudflareBlock, + networkErrorMessage, +} from './errors'; + +describe('classifyHttpStatus', () => { + it('maps firewall/rate-limit statuses to blocked', () => { + expect(classifyHttpStatus(403)).toBe('blocked'); + expect(classifyHttpStatus(429)).toBe('blocked'); + }); + + it('maps missing content to notFound', () => { + expect(classifyHttpStatus(404)).toBe('notFound'); + expect(classifyHttpStatus(410)).toBe('notFound'); + }); + + it('maps 5xx to serverError and other 4xx to httpError', () => { + expect(classifyHttpStatus(500)).toBe('serverError'); + expect(classifyHttpStatus(503)).toBe('serverError'); + expect(classifyHttpStatus(418)).toBe('httpError'); + }); +}); + +describe('isCloudflareBlock / httpErrorDetail', () => { + it('detects the Cloudflare firewall page in a 403 body', () => { + const body = 'Attention Required! | Cloudflare Sorry, you have been blocked'; + expect(isCloudflareBlock(403, body)).toBe(true); + expect(httpErrorDetail({ status: 403, body })).toBe('Cloudflare firewall block page'); + }); + + it('does not flag non-403 or plain bodies', () => { + expect(isCloudflareBlock(500, 'cloudflare')).toBe(false); + expect(isCloudflareBlock(403, 'plain')).toBe(false); + }); +}); + +describe('classifyTransportError', () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it('classifies aborts as timeout without probing', async () => { + const err = new DOMException('The user aborted a request.', 'AbortError'); + const result = await classifyTransportError(err, 'https://x/y'); + expect(result).toBeInstanceOf(AppNetworkError); + expect(result.kind).toBe('timeout'); + }); + + it('classifies as offline when the connectivity probe also fails', async () => { + globalThis.fetch = (async () => { + throw new TypeError('Network request failed'); + }) as unknown as typeof fetch; + const result = await classifyTransportError( + new TypeError('Network request failed'), + 'https://x/y' + ); + expect(result.kind).toBe('offline'); + }); + + it('classifies as unreachable when the probe succeeds but the site failed', async () => { + // Probe target is cp.cloudflare.com — only that host answers. + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).includes('cp.cloudflare.com')) return { ok: true, status: 204 }; + throw new TypeError('Network request failed'); + }) as unknown as typeof fetch; + const result = await classifyTransportError( + new TypeError('Network request failed'), + 'https://x/y' + ); + expect(result.kind).toBe('unreachable'); + }); +}); + +describe('networkErrorMessage', () => { + const t = (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key; + + it('maps AppNetworkError kinds to their i18n key', () => { + const err = new AppNetworkError('blocked', { status: 403 }); + expect(networkErrorMessage(err, t)).toBe('errors.blocked:{"status":403}'); + }); + + it('falls back to the unknown key for non-network errors', () => { + expect(networkErrorMessage(new Error('boom'), t)).toBe('errors.unknown'); + }); +}); diff --git a/src/lib/network/errors.ts b/src/lib/network/errors.ts new file mode 100644 index 0000000..d80c664 --- /dev/null +++ b/src/lib/network/errors.ts @@ -0,0 +1,144 @@ +import type { TxKeyPath } from '@/lib/i18n/types'; + +/** + * Classified network errors. + * + * React Native surfaces every transport-level failure (DNS, refused, TLS + * reset, firewall drop) as an opaque `TypeError: Network request failed`, + * and a 403 from the site's Cloudflare firewall used to render as a generic + * "failed to load" that looked identical to a broken search. This module + * turns failures into `AppNetworkError`s carrying a `kind` the UI can map + * to a specific, actionable message. + * + * Transport failures are disambiguated with a connectivity probe: if a + * known-good endpoint answers, the device is online and the site itself is + * refusing/dropping us (IP block, TLS interference); if it doesn't, the + * device has no usable internet. + */ + +export type NetworkErrorKind = + | 'timeout' + | 'blocked' + | 'notFound' + | 'serverError' + | 'httpError' + | 'offline' + | 'unreachable' + | 'unknown'; + +export class AppNetworkError extends Error { + readonly kind: NetworkErrorKind; + readonly status?: number; + readonly url?: string; + /** Raw underlying error/response text, kept for the in-app log viewer. */ + readonly detail?: string; + + constructor( + kind: NetworkErrorKind, + opts: { message?: string; status?: number; url?: string; detail?: string } = {} + ) { + super(opts.message ?? kind); + this.name = 'AppNetworkError'; + this.kind = kind; + this.status = opts.status; + this.url = opts.url; + this.detail = opts.detail; + } +} + +/** i18n key for each kind — screens render these with the app's translate fn. */ +export const NETWORK_ERROR_MESSAGES: Record = { + timeout: 'errors.timeout', + blocked: 'errors.blocked', + notFound: 'errors.notFound', + serverError: 'errors.serverError', + httpError: 'errors.httpError', + offline: 'errors.offline', + unreachable: 'errors.unreachable', + unknown: 'errors.unknown', +}; + +/** Translate fn of the app's i18n setup (subset used here, keeps this module testable). */ +type Translate = (key: TxKeyPath, params?: Record) => string; + +export function networkErrorMessage(err: unknown, t: Translate): string { + if (err instanceof AppNetworkError) { + return t(NETWORK_ERROR_MESSAGES[err.kind], { status: err.status ?? '' }); + } + return t('errors.unknown'); +} + +const CLOUDFLARE_BLOCK_MARKERS = + /cloudflare|cf-error-details|attention required|you have been blocked|cf-wrapper/i; + +/** Classify a non-2xx HTTP response by status (+ response body sniffing). */ +export function classifyHttpStatus(status: number): NetworkErrorKind { + if (status === 403 || status === 429) return 'blocked'; + if (status === 404 || status === 410) return 'notFound'; + if (status >= 500) return 'serverError'; + return 'httpError'; +} + +export function isCloudflareBlock(status: number, body = ''): boolean { + return status === 403 && CLOUDFLARE_BLOCK_MARKERS.test(body); +} + +/** A short, locale-independent summary line for the log viewer / detail field. */ +export function httpErrorDetail(err: { status: number; body?: string }): string { + if (isCloudflareBlock(err.status, err.body)) { + return 'Cloudflare firewall block page'; + } + return err.body ? err.body.slice(0, 200).replace(/\s+/g, ' ').trim() : ''; +} + +const PROBE_URL = 'https://cp.cloudflare.com/generate_204'; +const PROBE_TIMEOUT_MS = 5_000; + +/** + * Connectivity probe: fetch a tiny, highly-available endpoint (Cloudflare's + * captive-portal check). `true` means the device has usable internet, so a + * failing site request is the site's edge refusing/dropping us. + */ +export async function probeInternet(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + await fetch(PROBE_URL, { + signal: controller.signal, + headers: { Accept: '*/*' }, + cache: 'no-store', + } as RequestInit); + // Any answer at all (even a proxy error page) proves transport works. + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +function isAbortError(err: unknown): boolean { + return ( + (err instanceof DOMException && err.name === 'AbortError') || + (err instanceof Error && /abort/i.test(err.name)) || + (err instanceof Error && /aborted/i.test(err.message)) + ); +} + +/** + * Classify a transport-level fetch failure. Aborts map to timeout; anything + * else runs the connectivity probe to tell "device offline" apart from + * "site unreachable/blocked". + */ +export async function classifyTransportError(err: unknown, url?: string): Promise { + const raw = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + if (isAbortError(err)) { + return new AppNetworkError('timeout', { message: 'Request timed out', url, detail: raw }); + } + const online = await probeInternet(); + return new AppNetworkError(online ? 'unreachable' : 'offline', { + message: online ? 'Site unreachable (connection dropped or refused)' : 'No internet', + url, + detail: raw, + }); +} diff --git a/src/translations/en.json b/src/translations/en.json index 0e73fdc..76f7f11 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -107,5 +107,15 @@ "share": "Share", "followAuthor": "Follow author", "unfollowAuthor": "Unfollow author" + }, + "errors": { + "timeout": "Request timed out — the network is too slow or the site is unresponsive", + "blocked": "Access denied ({{status}}) — your network/IP is probably blocked by the site firewall (Cloudflare)", + "notFound": "Content doesn't exist or was removed (404)", + "serverError": "Site server error ({{status}})", + "httpError": "Request rejected ({{status}})", + "offline": "No internet connection — check your network", + "unreachable": "Internet works, but the site cannot be reached — your IP/region may be blocked or the connection is being interfered with", + "unknown": "Unknown error" } } diff --git a/src/translations/ja.json b/src/translations/ja.json index 283cdbe..83d3b4b 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -107,5 +107,15 @@ "share": "共有", "followAuthor": "作者をフォロー", "unfollowAuthor": "作者のフォローを解除" + }, + "errors": { + "timeout": "リクエストがタイムアウトしました——ネットワークが遅いか、サイトが応答していません", + "blocked": "アクセスが拒否されました({{status}})——現在のネットワーク/IPがサイトのファイアウォール(Cloudflare)にブロックされた可能性があります", + "notFound": "コンテンツが存在しないか、削除されました(404)", + "serverError": "サイトのサーバーエラー({{status}})", + "httpError": "リクエストが拒否されました({{status}})", + "offline": "インターネットに接続できません——ネットワークを確認してください", + "unreachable": "インターネットは利用できますが、サイトに接続できません——IP/地域がブロックされているか、接続が妨害されている可能性があります", + "unknown": "不明なエラー" } } diff --git a/src/translations/ko.json b/src/translations/ko.json index 0e53cf6..c4b97dc 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -107,5 +107,15 @@ "share": "공유", "followAuthor": "작가 팔로우", "unfollowAuthor": "작가 팔로우 취소" + }, + "errors": { + "timeout": "요청 시간 초과 — 네트워크가 느리거나 사이트가 응답하지 않습니다", + "blocked": "액세스가 거부되었습니다({{status}}) — 현재 네트워크/IP가 사이트 방화벽(Cloudflare)에 차단되었을 수 있습니다", + "notFound": "콘텐츠가 없거나 삭제되었습니다(404)", + "serverError": "사이트 서버 오류({{status}})", + "httpError": "요청이 거부되었습니다({{status}})", + "offline": "인터넷에 연결할 수 없습니다 — 네트워크를 확인하세요", + "unreachable": "인터넷은 정상이지만 사이트에 연결할 수 없습니다 — IP/지역이 차단되었거나 연결이 방해받고 있을 수 있습니다", + "unknown": "알 수 없는 오류" } } diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json index d9f0dbd..432a452 100644 --- a/src/translations/zh-CN.json +++ b/src/translations/zh-CN.json @@ -107,5 +107,15 @@ "share": "分享", "followAuthor": "追踪作者", "unfollowAuthor": "取消追踪作者" + }, + "errors": { + "timeout": "请求超时——网络太慢或站点无响应", + "blocked": "访问被拒绝({{status}})——当前网络/IP 可能被站点防火墙(Cloudflare)拦截", + "notFound": "内容不存在或已被删除(404)", + "serverError": "站点服务器错误({{status}})", + "httpError": "请求被拒绝({{status}})", + "offline": "设备无法连接互联网,请检查网络", + "unreachable": "网络正常,但无法连接到站点——当前 IP/区域可能被站点屏蔽,或连接被干扰", + "unknown": "发生未知错误" } } diff --git a/src/translations/zh.json b/src/translations/zh.json index dd3026e..9edda84 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -107,5 +107,15 @@ "share": "分享", "followAuthor": "追蹤作者", "unfollowAuthor": "取消追蹤作者" + }, + "errors": { + "timeout": "請求逾時——網路太慢或站點無回應", + "blocked": "存取被拒({{status}})——目前網路/IP 可能被站點防火牆(Cloudflare)攔截", + "notFound": "內容不存在或已被刪除(404)", + "serverError": "站點伺服器錯誤({{status}})", + "httpError": "請求被拒({{status}})", + "offline": "裝置無法連線網際網路,請檢查網路", + "unreachable": "網路正常,但無法連線站點——目前 IP/區域可能被站點封鎖,或連線受干擾", + "unknown": "發生未知錯誤" } } From e8709015522ca22bac9d31cd2977e77c357cfbf8 Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Sat, 26 Sep 2026 04:17:55 +0800 Subject: [PATCH 2/2] feat: in-app error log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every classified failure appends one line to documents/logs/error-log.txt (128KB soft cap, oldest-first trimming). Settings → Error log renders the file as selectable text — long-press to copy via the system menu — with share (whole log) and clear actions. No new dependencies. --- src/app/(app)/settings.tsx | 8 ++++ src/app/_layout.tsx | 10 +++++ src/app/logs.tsx | 81 +++++++++++++++++++++++++++++++++ src/lib/hanime1/scraper.ts | 16 ++++++- src/lib/logs/error-log.test.ts | 48 ++++++++++++++++++++ src/lib/logs/error-log.ts | 82 ++++++++++++++++++++++++++++++++++ src/translations/en.json | 12 ++++- src/translations/ja.json | 12 ++++- src/translations/ko.json | 12 ++++- src/translations/zh-CN.json | 12 ++++- src/translations/zh.json | 12 ++++- 11 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 src/app/logs.tsx create mode 100644 src/lib/logs/error-log.test.ts create mode 100644 src/lib/logs/error-log.ts diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 32def8a..c326a93 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -16,6 +16,7 @@ import { Env } from '@env'; // bundles (binding missing at runtime), destructuring after import works. import * as ApplicationNS from 'expo-application'; import { ActivityAction, startActivityAsync } from 'expo-intent-launcher'; +import { Link } from 'expo-router'; import { ActivityIndicator, Platform, @@ -222,6 +223,13 @@ export default function SettingsScreen() { {t('common.clear')} + + + + + + + {t('settings.about')} + ( + navigation.goBack()} /> + ), + }} + /> diff --git a/src/app/logs.tsx b/src/app/logs.tsx new file mode 100644 index 0000000..10ff576 --- /dev/null +++ b/src/app/logs.tsx @@ -0,0 +1,81 @@ +import { ScreenErrorBoundary } from '@/components/error-boundary'; +import { Icon } from '@/components/icon'; +import { SafeAreaView } from '@/components/safe-area-view'; +import { useTranslate } from '@/lib/i18n/utils'; +import { clearErrorLog, readErrorLog } from '@/lib/logs/error-log'; +import { useFocusEffect } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, Share, Text, View } from 'react-native'; +import { showMessage } from 'react-native-flash-message'; + +export { ScreenErrorBoundary as ErrorBoundary }; + +/** + * Viewer for the plain-text error log (one line per classified network + * failure). The content is a single selectable — long-press and use + * the system copy menu to copy any range; Share sends the whole file. + */ +export default function ErrorLogScreen() { + const t = useTranslate(); + // null = not loaded yet, '' = empty log. + const [log, setLog] = useState(null); + + useFocusEffect( + useCallback(() => { + void readErrorLog().then(setLog); + }, []) + ); + + const clear = async () => { + await clearErrorLog(); + setLog(''); + showMessage({ message: t('logs.cleared'), type: 'success', position: 'top', duration: 1500 }); + }; + + return ( + + + {t('logs.title')} + + void Share.share({ title: t('logs.title'), message: log ?? '' })} + disabled={!log} + className={`flex-row items-center gap-1.5 rounded-full px-3 py-1.5 ${ + log ? 'bg-muted' : 'bg-muted/50' + }`} + > + + {t('logs.share')} + + void clear()} + disabled={!log} + className="flex-row items-center rounded-full bg-muted/50 px-3 py-1.5" + > + {t('logs.clear')} + + + + + + {t('logs.copyHint')} + + + {log === null ? ( + + + + ) : log === '' ? ( + + {t('logs.empty')} + + ) : ( + + + {log} + + + )} + + ); +} diff --git a/src/lib/hanime1/scraper.ts b/src/lib/hanime1/scraper.ts index f99c8e6..c530801 100644 --- a/src/lib/hanime1/scraper.ts +++ b/src/lib/hanime1/scraper.ts @@ -8,6 +8,7 @@ * named captures the way V8 does. We therefore use **positional** capture * groups (`match[1..n]`) exclusively and avoid `.groups`. */ +import { appendErrorLog } from '@/lib/logs/error-log'; import { AppNetworkError, classifyHttpStatus, @@ -31,10 +32,21 @@ function forEachMatch(input: string, pattern: RegExp, cb: (m: RegExpExecArray) = } } +/** Record a failure in the error-log file (see Settings → Error log). */ +function logNetworkError(err: AppNetworkError): void { + void appendErrorLog({ + kind: err.kind, + status: err.status, + url: err.url, + detail: err.detail || err.message, + }); +} + /** * Fetch a page as text with a desktop UA + timeout. Failures are classified * (timeout / Cloudflare block / offline / site-unreachable / …) into - * `AppNetworkError`s the UI can render specific messages for. + * `AppNetworkError`s the UI can render specific messages for, and recorded + * in the in-app error log. */ export async function fetchPage(pathOrUrl: string): Promise { const url = pathOrUrl.startsWith('http') ? pathOrUrl : buildUrl(pathOrUrl); @@ -57,12 +69,14 @@ export async function fetchPage(pathOrUrl: string): Promise { url, detail: httpErrorDetail({ status: res.status, body }), }); + logNetworkError(err); throw err; } return await res.text(); } catch (err) { if (err instanceof AppNetworkError) throw err; const appErr = await classifyTransportError(err, url); + logNetworkError(appErr); throw appErr; } finally { clearTimeout(timeout); diff --git a/src/lib/logs/error-log.test.ts b/src/lib/logs/error-log.test.ts new file mode 100644 index 0000000..1f9a55a --- /dev/null +++ b/src/lib/logs/error-log.test.ts @@ -0,0 +1,48 @@ +jest.mock('expo-file-system/legacy', () => ({ + documentDirectory: 'file:///doc/', + makeDirectoryAsync: jest.fn(), + readAsStringAsync: jest.fn().mockResolvedValue(''), + writeAsStringAsync: jest.fn(), + deleteAsync: jest.fn(), +})); + +import * as FileSystem from 'expo-file-system/legacy'; +import { appendErrorLog, formatErrorLine, formatTimestamp } from './error-log'; + +describe('formatErrorLine', () => { + it('renders one line with timestamp, kind, status, url and detail', () => { + const line = formatErrorLine( + { kind: 'blocked', status: 403, url: 'https://x/y', detail: 'Cloudflare block\npage' }, + new Date('2026-09-26T04:05:06').getTime() + ); + expect(line).toBe('[2026-09-26 04:05:06] blocked HTTP 403 https://x/y — Cloudflare block page'); + }); + + it('omits absent status/url/detail', () => { + expect(formatErrorLine({ kind: 'offline' }, 0)).toMatch(/offline/); + expect(formatErrorLine({ kind: 'offline' }, 0)).not.toContain('HTTP'); + }); +}); + +describe('formatTimestamp', () => { + it('zero-pads components', () => { + expect(formatTimestamp(new Date(2026, 0, 2, 3, 4, 5).getTime())).toBe('2026-01-02 03:04:05'); + }); +}); + +describe('appendErrorLog', () => { + it('appends the formatted line to the existing content', async () => { + (FileSystem.readAsStringAsync as jest.Mock).mockResolvedValueOnce('first\n'); + await appendErrorLog({ kind: 'timeout' }); + const [path, content] = (FileSystem.writeAsStringAsync as jest.Mock).mock.calls[0]; + expect(path).toBe('file:///doc/logs/error-log.txt'); + expect(content.startsWith('first\n')).toBe(true); + expect(content).toContain('timeout'); + expect(content.endsWith('\n')).toBe(true); + }); + + it('never throws when the filesystem fails', async () => { + (FileSystem.writeAsStringAsync as jest.Mock).mockRejectedValueOnce(new Error('disk full')); + await expect(appendErrorLog({ kind: 'unknown' })).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/logs/error-log.ts b/src/lib/logs/error-log.ts new file mode 100644 index 0000000..f4b5bdb --- /dev/null +++ b/src/lib/logs/error-log.ts @@ -0,0 +1,82 @@ +import * as FileSystem from 'expo-file-system/legacy'; + +/** + * Plain-text error log, appended by the network layer on every classified + * failure and viewable in Settings → Error log. One line per entry keeps the + * file trimmable, shareable and manually copyable — no database, no store. + */ + +const LOG_DIR = `${FileSystem.documentDirectory}logs/`; +export const ERROR_LOG_FILE = `${LOG_DIR}error-log.txt`; + +/** Soft cap for the file (chars ≈ bytes here — lines are ASCII-dominated). */ +const MAX_CHARS = 128 * 1024; + +export type ErrorLogRecord = { + kind: string; + status?: number; + url?: string; + detail?: string; +}; + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +export function formatTimestamp(epoch: number): string { + const d = new Date(epoch); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad( + d.getMinutes() + )}:${pad(d.getSeconds())}`; +} + +/** Single line: `[2026-09-26 12:03:44] blocked HTTP 403 https://… — detail` */ +export function formatErrorLine(record: ErrorLogRecord, time = Date.now()): string { + const parts = [`[${formatTimestamp(time)}] ${record.kind}`]; + if (record.status) parts.push(`HTTP ${record.status}`); + if (record.url) parts.push(record.url); + if (record.detail) parts.push(`— ${record.detail.replace(/\s+/g, ' ').trim()}`); + return parts.join(' '); +} + +async function ensureDir(): Promise { + await FileSystem.makeDirectoryAsync(LOG_DIR, { intermediates: true }); +} + +/** Append one entry, trimming the oldest content once the cap is exceeded. */ +export async function appendErrorLog(record: ErrorLogRecord): Promise { + try { + await ensureDir(); + let content = ''; + try { + content = await FileSystem.readAsStringAsync(ERROR_LOG_FILE, { encoding: 'utf8' }); + } catch { + // First entry — the file doesn't exist yet. + } + const line = formatErrorLine(record); + content = content && !content.endsWith('\n') ? `${content}\n${line}\n` : `${content}${line}\n`; + if (content.length > MAX_CHARS) { + const trimmed = content.slice(-MAX_CHARS); + content = trimmed.slice(trimmed.indexOf('\n') + 1); // resume at a line start + } + await FileSystem.writeAsStringAsync(ERROR_LOG_FILE, content, { encoding: 'utf8' }); + } catch { + // Logging must never break the request path. + } +} + +export async function readErrorLog(): Promise { + try { + return await FileSystem.readAsStringAsync(ERROR_LOG_FILE, { encoding: 'utf8' }); + } catch { + return ''; + } +} + +export async function clearErrorLog(): Promise { + try { + await FileSystem.deleteAsync(ERROR_LOG_FILE, { idempotent: true }); + } catch { + // ignore + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 76f7f11..3ce0c44 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -98,7 +98,9 @@ "updateAvailablePrefix": "New version", "updateAvailableSuffix": "available", "releaseDate": "Released", - "releaseNotes": "What's new:" + "releaseNotes": "What's new:", + "errorLog": "Error log", + "errorLogDescription": "View recent network errors (copyable)" }, "actions": { "addToFavorites": "Add to favorites", @@ -117,5 +119,13 @@ "offline": "No internet connection — check your network", "unreachable": "Internet works, but the site cannot be reached — your IP/region may be blocked or the connection is being interfered with", "unknown": "Unknown error" + }, + "logs": { + "title": "Error log", + "empty": "No errors yet", + "share": "Share", + "clear": "Clear", + "cleared": "Log cleared", + "copyHint": "Long-press the text to select and copy; Share sends the whole log." } } diff --git a/src/translations/ja.json b/src/translations/ja.json index 83d3b4b..5b67801 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -98,7 +98,9 @@ "updateAvailablePrefix": "新しいバージョン", "updateAvailableSuffix": "があります", "releaseDate": "リリース日", - "releaseNotes": "更新内容:" + "releaseNotes": "更新内容:", + "errorLog": "エラーログ", + "errorLogDescription": "最近のネットワークエラーを表示(コピー可能)" }, "actions": { "addToFavorites": "お気に入りに追加", @@ -117,5 +119,13 @@ "offline": "インターネットに接続できません——ネットワークを確認してください", "unreachable": "インターネットは利用できますが、サイトに接続できません——IP/地域がブロックされているか、接続が妨害されている可能性があります", "unknown": "不明なエラー" + }, + "logs": { + "title": "エラーログ", + "empty": "エラーはまだありません", + "share": "共有", + "clear": "クリア", + "cleared": "ログを消去しました", + "copyHint": "テキストを長押しで選択してコピーできます。共有でログ全体を送れます。" } } diff --git a/src/translations/ko.json b/src/translations/ko.json index c4b97dc..91d16b7 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -98,7 +98,9 @@ "updateAvailablePrefix": "새 버전", "updateAvailableSuffix": "사용 가능", "releaseDate": "릴리스", - "releaseNotes": "업데이트 내용:" + "releaseNotes": "업데이트 내용:", + "errorLog": "오류 로그", + "errorLogDescription": "최근 네트워크 오류 보기(복사 가능)" }, "actions": { "addToFavorites": "즐겨찾기에 추가", @@ -117,5 +119,13 @@ "offline": "인터넷에 연결할 수 없습니다 — 네트워크를 확인하세요", "unreachable": "인터넷은 정상이지만 사이트에 연결할 수 없습니다 — IP/지역이 차단되었거나 연결이 방해받고 있을 수 있습니다", "unknown": "알 수 없는 오류" + }, + "logs": { + "title": "오류 로그", + "empty": "아직 오류가 없습니다", + "share": "공유", + "clear": "지우기", + "cleared": "로그를 지웠습니다", + "copyHint": "텍스트를 길게 눌러 선택하고 복사할 수 있습니다. 공유로 전체 로그를 보낼 수 있습니다." } } diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json index 432a452..d62659a 100644 --- a/src/translations/zh-CN.json +++ b/src/translations/zh-CN.json @@ -98,7 +98,9 @@ "updateAvailablePrefix": "有新版本", "updateAvailableSuffix": "可供更新", "releaseDate": "发布于", - "releaseNotes": "更新说明:" + "releaseNotes": "更新说明:", + "errorLog": "错误日志", + "errorLogDescription": "查看最近的网络错误详情,可复制" }, "actions": { "addToFavorites": "加入收藏", @@ -117,5 +119,13 @@ "offline": "设备无法连接互联网,请检查网络", "unreachable": "网络正常,但无法连接到站点——当前 IP/区域可能被站点屏蔽,或连接被干扰", "unknown": "发生未知错误" + }, + "logs": { + "title": "错误日志", + "empty": "暂无错误日志", + "share": "分享", + "clear": "清空", + "cleared": "日志已清空", + "copyHint": "长按文字即可选中复制;分享可导出整个日志。" } } diff --git a/src/translations/zh.json b/src/translations/zh.json index 9edda84..eceb963 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -98,7 +98,9 @@ "updateAvailablePrefix": "有新版本", "updateAvailableSuffix": "可供更新", "releaseDate": "發布於", - "releaseNotes": "更新說明:" + "releaseNotes": "更新說明:", + "errorLog": "錯誤日誌", + "errorLogDescription": "檢視最近的網路錯誤詳情,可複製" }, "actions": { "addToFavorites": "加入收藏", @@ -117,5 +119,13 @@ "offline": "裝置無法連線網際網路,請檢查網路", "unreachable": "網路正常,但無法連線站點——目前 IP/區域可能被站點封鎖,或連線受干擾", "unknown": "發生未知錯誤" + }, + "logs": { + "title": "錯誤日誌", + "empty": "尚無錯誤日誌", + "share": "分享", + "clear": "清空", + "cleared": "日誌已清空", + "copyHint": "長按文字即可選取複製;分享可匯出整個日誌。" } }