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/(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/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..c530801 100644
--- a/src/lib/hanime1/scraper.ts
+++ b/src/lib/hanime1/scraper.ts
@@ -8,6 +8,13 @@
* 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,
+ classifyTransportError,
+ httpErrorDetail,
+} from '@/lib/network/errors';
import { buildSearchUrl, buildUrl, endpoints } from './endpoints';
import type { ListResult, VideoDetail, VideoListItem, VideoSource, VideoTag } from './types';
@@ -15,17 +22,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 +32,22 @@ function forEachMatch(input: string, pattern: RegExp, cb: (m: RegExpExecArray) =
}
}
-/** Fetch a page as text with a desktop UA + timeout. */
+/** 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, and recorded
+ * in the in-app error log.
+ */
export async function fetchPage(pathOrUrl: string): Promise {
const url = pathOrUrl.startsWith('http') ? pathOrUrl : buildUrl(pathOrUrl);
const controller = new AbortController();
@@ -51,14 +62,22 @@ 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 }),
+ });
+ logNetworkError(err);
+ 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);
+ 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/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..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",
@@ -107,5 +109,23 @@
"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"
+ },
+ "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 283cdbe..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": "お気に入りに追加",
@@ -107,5 +109,23 @@
"share": "共有",
"followAuthor": "作者をフォロー",
"unfollowAuthor": "作者のフォローを解除"
+ },
+ "errors": {
+ "timeout": "リクエストがタイムアウトしました——ネットワークが遅いか、サイトが応答していません",
+ "blocked": "アクセスが拒否されました({{status}})——現在のネットワーク/IPがサイトのファイアウォール(Cloudflare)にブロックされた可能性があります",
+ "notFound": "コンテンツが存在しないか、削除されました(404)",
+ "serverError": "サイトのサーバーエラー({{status}})",
+ "httpError": "リクエストが拒否されました({{status}})",
+ "offline": "インターネットに接続できません——ネットワークを確認してください",
+ "unreachable": "インターネットは利用できますが、サイトに接続できません——IP/地域がブロックされているか、接続が妨害されている可能性があります",
+ "unknown": "不明なエラー"
+ },
+ "logs": {
+ "title": "エラーログ",
+ "empty": "エラーはまだありません",
+ "share": "共有",
+ "clear": "クリア",
+ "cleared": "ログを消去しました",
+ "copyHint": "テキストを長押しで選択してコピーできます。共有でログ全体を送れます。"
}
}
diff --git a/src/translations/ko.json b/src/translations/ko.json
index 0e53cf6..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": "즐겨찾기에 추가",
@@ -107,5 +109,23 @@
"share": "공유",
"followAuthor": "작가 팔로우",
"unfollowAuthor": "작가 팔로우 취소"
+ },
+ "errors": {
+ "timeout": "요청 시간 초과 — 네트워크가 느리거나 사이트가 응답하지 않습니다",
+ "blocked": "액세스가 거부되었습니다({{status}}) — 현재 네트워크/IP가 사이트 방화벽(Cloudflare)에 차단되었을 수 있습니다",
+ "notFound": "콘텐츠가 없거나 삭제되었습니다(404)",
+ "serverError": "사이트 서버 오류({{status}})",
+ "httpError": "요청이 거부되었습니다({{status}})",
+ "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 d9f0dbd..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": "加入收藏",
@@ -107,5 +109,23 @@
"share": "分享",
"followAuthor": "追踪作者",
"unfollowAuthor": "取消追踪作者"
+ },
+ "errors": {
+ "timeout": "请求超时——网络太慢或站点无响应",
+ "blocked": "访问被拒绝({{status}})——当前网络/IP 可能被站点防火墙(Cloudflare)拦截",
+ "notFound": "内容不存在或已被删除(404)",
+ "serverError": "站点服务器错误({{status}})",
+ "httpError": "请求被拒绝({{status}})",
+ "offline": "设备无法连接互联网,请检查网络",
+ "unreachable": "网络正常,但无法连接到站点——当前 IP/区域可能被站点屏蔽,或连接被干扰",
+ "unknown": "发生未知错误"
+ },
+ "logs": {
+ "title": "错误日志",
+ "empty": "暂无错误日志",
+ "share": "分享",
+ "clear": "清空",
+ "cleared": "日志已清空",
+ "copyHint": "长按文字即可选中复制;分享可导出整个日志。"
}
}
diff --git a/src/translations/zh.json b/src/translations/zh.json
index dd3026e..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": "加入收藏",
@@ -107,5 +109,23 @@
"share": "分享",
"followAuthor": "追蹤作者",
"unfollowAuthor": "取消追蹤作者"
+ },
+ "errors": {
+ "timeout": "請求逾時——網路太慢或站點無回應",
+ "blocked": "存取被拒({{status}})——目前網路/IP 可能被站點防火牆(Cloudflare)攔截",
+ "notFound": "內容不存在或已被刪除(404)",
+ "serverError": "站點伺服器錯誤({{status}})",
+ "httpError": "請求被拒({{status}})",
+ "offline": "裝置無法連線網際網路,請檢查網路",
+ "unreachable": "網路正常,但無法連線站點——目前 IP/區域可能被站點封鎖,或連線受干擾",
+ "unknown": "發生未知錯誤"
+ },
+ "logs": {
+ "title": "錯誤日誌",
+ "empty": "尚無錯誤日誌",
+ "share": "分享",
+ "clear": "清空",
+ "cleared": "日誌已清空",
+ "copyHint": "長按文字即可選取複製;分享可匯出整個日誌。"
}
}