diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index b76d180..57e5d68 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -3,7 +3,7 @@ import { Image } from 'expo-image'; import * as LocalAuthentication from 'expo-local-authentication'; import type * as React from 'react'; import { useEffect, useState } from 'react'; -import { Alert, Platform, Pressable, TouchableOpacity } from 'react-native'; +import { ActivityIndicator, Alert, Platform, Pressable, TouchableOpacity } from 'react-native'; import { AppIconItem } from '@/components/settings/app-icon-item'; import { ItemsContainer } from '@/components/settings/items-container'; @@ -11,7 +11,9 @@ import { LanguageItem } from '@/components/settings/language-item'; import { ThemeItem } from '@/components/settings/theme-item'; import { FocusAwareStatusBar, SafeAreaView, ScrollView, Text, View, colors } from '@/components/ui'; import { Trash } from '@/components/ui/icons'; +import { UpdateDialog } from '@/components/update-dialog'; import { LOCK_TIMEOUT_OPTIONS, useSecuritySettings } from '@/lib/hooks/use-security-settings'; +import { useUpdateCheck } from '@/lib/hooks/use-update-check'; import { useTranslate } from '@/lib/i18n'; import { useDownloadedStore } from '@/lib/stores/downloaded-store'; import { ORIENTATIONS, useOrientationStore } from '@/lib/stores/orientation-store'; @@ -25,17 +27,23 @@ const SITE_DOMAIN = 'rule34video.com'; /** * A plain-text settings row. `label` is a pre-translated string passed in by * the caller (build it via useTranslate() with a settings.* translation key). + * `loading` shows a spinner left of the value (used by the update check). */ function Row({ label, value, + valueClassName, icon, onPress, + loading, }: { label: string; value?: string; + /** Extra classes for the value text (e.g. highlighting a new version). */ + valueClassName?: string; icon?: React.ReactNode; onPress?: () => void; + loading?: boolean; }) { const actionable = onPress !== undefined; return ( @@ -48,7 +56,16 @@ function Row({ {icon ? {icon} : null} {label} - {value ? {value} : null} + {value ? ( + + {loading ? : null} + + {value} + + + ) : loading ? ( + + ) : null} ); } @@ -65,6 +82,14 @@ export default function Settings() { const { appLock, setAppLock, lockTimeoutMs, setLockTimeoutMs, hidePreview, setHidePreview } = useSecuritySettings(); const [biometricsAvailable, setBiometricsAvailable] = useState(false); + const { + checking, + newerRelease, + pendingRelease, + runCheck, + dismissUpdateDialog, + openReleaseDownload, + } = useUpdateCheck(Env.VERSION); useEffect(() => { LocalAuthentication.hasHardwareAsync().then((has) => { @@ -295,13 +320,28 @@ export default function Settings() { {/* About */} - + runCheck(true)} + /> + + ); } diff --git a/src/components/update-dialog.test.tsx b/src/components/update-dialog.test.tsx new file mode 100644 index 0000000..219ddd1 --- /dev/null +++ b/src/components/update-dialog.test.tsx @@ -0,0 +1,86 @@ +jest.mock('react-native-flash-message', () => ({ + showMessage: jest.fn(), +})); + +import { cleanup, fireEvent, render, screen } from '@/lib/test-utils'; +import type { ReleaseInfo } from '@/lib/updater'; + +import { UpdateDialog } from './update-dialog'; + +const release: ReleaseInfo = { + version: '0.4.0', + title: 'v0.4.0', + notes: "What's Changed\n• fix one\n• feat two", + releaseUrl: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0', + apkUrl: 'https://github.com/ghostcoder42/r34/releases/download/v0.4.0/r34-0.4.0.apk', + apkSize: 44040192, // 42 MB + publishedAt: '2026-09-05T10:00:00Z', +}; + +const noop = () => {}; + +afterEach(cleanup); + +describe('UpdateDialog', () => { + it('renders nothing without a release', () => { + render(); + + expect(screen.queryByText(/0\.4\.0/)).toBeNull(); + }); + + it('shows version, message, meta line and notes', () => { + render( + + ); + + // Title carries the new version; the message mentions the current one. + expect(screen.getAllByText(/0\.4\.0/).length).toBeGreaterThan(0); + expect(screen.getByText(/0\.3\.0/)).toBeTruthy(); + // Meta: release date (ISO slice, no Intl) and APK size. + expect(screen.getByText(/2026-09-05/)).toBeTruthy(); + expect(screen.getByText(/42\.0 MB/)).toBeTruthy(); + // Notes render (getByText matches across newlines in one Text node). + expect(screen.getByText(/What's Changed/)).toBeTruthy(); + expect(screen.getByText(/• fix one/)).toBeTruthy(); + }); + + it('hides the meta line and notes when absent', () => { + const minimal: ReleaseInfo = { + version: '0.4.0', + title: 'v0.4.0', + notes: '', + releaseUrl: release.releaseUrl, + apkUrl: null, + }; + + render( + + ); + + expect(screen.queryByText(/MB/)).toBeNull(); + expect(screen.queryByText(/What's new:/)).toBeNull(); + }); + + it('invokes onClose and onDownload from the buttons and backdrop', () => { + const onClose = jest.fn(); + const onDownload = jest.fn(); + + render( + + ); + + fireEvent.press(screen.getByTestId('update-dialog-download')); + expect(onDownload).toHaveBeenCalledWith(release); + + fireEvent.press(screen.getByTestId('update-dialog-cancel')); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.press(screen.getByTestId('update-dialog-backdrop')); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/update-dialog.tsx b/src/components/update-dialog.tsx new file mode 100644 index 0000000..efed92d --- /dev/null +++ b/src/components/update-dialog.tsx @@ -0,0 +1,106 @@ +import type * as React from 'react'; +import { Modal, Pressable, ScrollView, Text, View } from 'react-native'; + +import { useTranslate } from '@/lib/i18n'; +import type { ReleaseInfo } from '@/lib/updater'; + +type UpdateDialogProps = { + /** Release to present; null keeps the dialog closed. */ + release: ReleaseInfo | null; + currentVersion: string; + onClose: () => void; + onDownload: (release: ReleaseInfo) => void; +}; + +/** + * In-app "new version" card. The system Alert can't cap its height and + * GitHub release notes grow without bound, so this dialog clamps to 80% of + * the screen with the body scrolling inside. The card itself is a Pressable + * without onPress (tap sink) — only the backdrop dismisses, while scrolls + * and buttons inside keep working. + */ +export function UpdateDialog({ + release, + currentVersion, + onClose, + onDownload, +}: UpdateDialogProps): React.ReactElement | null { + const t = useTranslate(); + if (!release) return null; + + // np names releases after the tag; only show the title when it says + // something the version number doesn't. + const showName = + !!release.title && release.title !== `v${release.version}` && release.title !== release.version; + + const meta: string[] = []; + if (release.publishedAt) { + // ISO string slice — no Intl/Date formatting needed (Hermes-safe). + meta.push(`${t('settings.release_date')} ${release.publishedAt.slice(0, 10)}`); + } + if (release.apkSize) { + meta.push(`APK ≈ ${(release.apkSize / 1024 / 1024).toFixed(1)} MB`); + } + + return ( + + + + + {t('settings.update_available_title', { version: release.version })} + + + + {t('settings.update_available_msg', { + version: release.version, + current: currentVersion, + })} + + {meta.length > 0 ? ( + + {meta.join(' · ')} + + ) : null} + {showName ? ( + + {release.title} + + ) : null} + {release.notes ? ( + + {t('settings.release_notes')} + {'\n'} + {release.notes} + + ) : null} + + + + {t('common.cancel')} + + onDownload(release)} + className="rounded-full bg-primary-500 px-4 py-2" + accessibilityRole="button" + testID="update-dialog-download" + > + {t('settings.update_now')} + + + + + + ); +} diff --git a/src/lib/hooks/use-update-check.test.ts b/src/lib/hooks/use-update-check.test.ts new file mode 100644 index 0000000..f33634e --- /dev/null +++ b/src/lib/hooks/use-update-check.test.ts @@ -0,0 +1,208 @@ +jest.mock('react-native-flash-message', () => ({ + showMessage: jest.fn(), +})); + +jest.mock('@/lib/r34/fetch-error', () => ({ + fetchErrorMessage: jest.fn(() => 'classified network message'), +})); + +// Controllable throttle store (real logic covered in update-check-store.test). +const mockCheckStore = { + autoCheckDue: true, + recordCheckAt: jest.fn(), +}; + +jest.mock('@/lib/stores/update-check-store', () => ({ + shouldAutoCheck: () => mockCheckStore.autoCheckDue, + recordCheckAt: (...args: unknown[]) => mockCheckStore.recordCheckAt(...args), +})); + +const defaultRelease: import('@/lib/updater').ReleaseInfo = { + version: '0.4.0', + title: 'v0.4.0', + notes: "What's Changed\n* fix x", + releaseUrl: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0', + apkUrl: null, +}; + +const mockUpdater = { + release: defaultRelease, + error: null as Error | null, + persisted: null as Record | null, + fetchLatestRelease: jest.fn(), + saveLastKnownRelease: jest.fn(), +}; + +jest.mock('@/lib/updater', () => ({ + fetchLatestRelease: (...args: unknown[]) => mockUpdater.fetchLatestRelease(...args), + isNewerVersion: (latest: string, current: string) => latest !== current && latest > current, + saveLastKnownRelease: (...args: unknown[]) => mockUpdater.saveLastKnownRelease(...args), + getLastKnownRelease: () => mockUpdater.persisted, +})); + +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { Alert, Linking, Platform } from 'react-native'; +import { showMessage } from 'react-native-flash-message'; + +import { useUpdateCheck } from './use-update-check'; + +jest.spyOn(Alert, 'alert').mockImplementation(() => {}); +jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined as never); +const setPlatform = (os: 'android' | 'ios') => + Object.defineProperty(Platform, 'OS', { value: os, configurable: true, writable: true }); + +beforeEach(() => { + jest.clearAllMocks(); + mockCheckStore.autoCheckDue = true; + mockUpdater.release = defaultRelease; + mockUpdater.error = null; + mockUpdater.persisted = null; + mockUpdater.fetchLatestRelease.mockImplementation(async () => { + if (mockUpdater.error) throw mockUpdater.error; + return mockUpdater.release; + }); +}); + +describe('useUpdateCheck', () => { + it('seeds the hint from the persisted last check so it survives restarts', () => { + mockUpdater.persisted = { version: '0.4.0', title: 'v0.4.0', notes: '' }; + // The automatic fetch never resolves — the seed alone must light the row. + mockUpdater.fetchLatestRelease.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + expect(result.current.newerRelease?.version).toBe('0.4.0'); + }); + + it('does not seed a persisted hint that no longer beats the installed version', () => { + // User already updated past the persisted find (e.g. installed manually). + mockUpdater.persisted = { version: '0.3.0', title: 'v0.3.0', notes: '' }; + mockUpdater.fetchLatestRelease.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + expect(result.current.newerRelease).toBeNull(); + }); + + it('persists every successful check, up to date or not', async () => { + const { result } = renderHook(() => useUpdateCheck('0.4.0')); // same as latest + await waitFor(() => expect(result.current.checking).toBe(false)); + + expect(mockUpdater.saveLastKnownRelease).toHaveBeenCalledWith(mockUpdater.release); + }); + + it('skips the automatic check when the daily throttle is not due', async () => { + mockCheckStore.autoCheckDue = false; + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + expect(mockUpdater.fetchLatestRelease).not.toHaveBeenCalled(); + expect(result.current.checking).toBe(false); + }); + + it('checks automatically on mount and only flags the row (no dialogs)', async () => { + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + await waitFor(() => expect(result.current.checking).toBe(false)); + expect(result.current.newerRelease?.version).toBe('0.4.0'); + expect(mockCheckStore.recordCheckAt).toHaveBeenCalledTimes(1); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(showMessage).not.toHaveBeenCalled(); + }); + + it('stays completely silent when the automatic check fails', async () => { + mockUpdater.error = new Error('Network request failed'); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + await waitFor(() => expect(result.current.checking).toBe(false)); + expect(result.current.newerRelease).toBeNull(); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(showMessage).not.toHaveBeenCalled(); + }); + + it('manual check with a new release opens the in-app dialog, not an Alert', async () => { + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + + await act(async () => { + await result.current.runCheck(true); + }); + + expect(Alert.alert).not.toHaveBeenCalled(); + expect(result.current.pendingRelease?.version).toBe('0.4.0'); + + act(() => result.current.dismissUpdateDialog()); + expect(result.current.pendingRelease).toBeNull(); + }); + + it('opens the APK direct link on Android and the release page on iOS', async () => { + mockUpdater.release = { + version: '0.4.0', + title: 'v0.4.0', + notes: '', + releaseUrl: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0', + apkUrl: 'https://github.com/ghostcoder42/r34/releases/download/v0.4.0/r34-0.4.0.apk', + }; + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + + setPlatform('android'); + result.current.openReleaseDownload(mockUpdater.release); + expect(Linking.openURL).toHaveBeenLastCalledWith(mockUpdater.release.apkUrl); + + setPlatform('ios'); + result.current.openReleaseDownload(mockUpdater.release); + expect(Linking.openURL).toHaveBeenLastCalledWith(mockUpdater.release.releaseUrl); + }); + + it('manual check toasts when already up to date', async () => { + // Same version installed as the latest release. + const { result } = renderHook(() => useUpdateCheck('0.4.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + + await act(async () => { + await result.current.runCheck(true); + }); + + expect(Alert.alert).not.toHaveBeenCalled(); + expect(showMessage).toHaveBeenCalledTimes(1); + expect((showMessage as jest.Mock).mock.calls[0][0].message).toContain('0.4.0'); + }); + + it('manual check shows a failure dialog with the classified cause', async () => { + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + mockUpdater.error = new Error('Failed to fetch release info: 503'); + + await act(async () => { + await result.current.runCheck(true); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + const [title, message] = (Alert.alert as jest.Mock).mock.calls[0]; + expect(title).toBeTruthy(); + expect(message).toBe('classified network message'); + }); + + it('ignores concurrent triggers while a check is in flight', async () => { + let resolveFetch: (v: unknown) => void = () => {}; + mockUpdater.fetchLatestRelease.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + // Mount fired the automatic check; tapping while it runs must not start + // a second one. + await act(async () => { + await result.current.runCheck(true); + }); + + expect(mockUpdater.fetchLatestRelease).toHaveBeenCalledTimes(1); + resolveFetch(mockUpdater.release); + await waitFor(() => expect(result.current.checking).toBe(false)); + }); +}); diff --git a/src/lib/hooks/use-update-check.ts b/src/lib/hooks/use-update-check.ts new file mode 100644 index 0000000..356eeb5 --- /dev/null +++ b/src/lib/hooks/use-update-check.ts @@ -0,0 +1,124 @@ +import * as React from 'react'; +import { Alert, Linking, Platform } from 'react-native'; +import { showMessage } from 'react-native-flash-message'; + +import i18n from '@/lib/i18n'; +import { fetchErrorMessage } from '@/lib/r34/fetch-error'; +import { recordCheckAt, shouldAutoCheck } from '@/lib/stores/update-check-store'; +import { + type ReleaseInfo, + fetchLatestRelease, + getLastKnownRelease, + isNewerVersion, + saveLastKnownRelease, +} from '@/lib/updater'; + +type UseUpdateCheck = { + /** True while a check is in flight (drives the row's spinner). */ + checking: boolean; + /** The newer release, when one was found; null otherwise. */ + newerRelease: ReleaseInfo | null; + /** Runs a check. Manual checks surface every outcome; automatic ones are + * silent unless a newer release is found (then only the row text changes). */ + runCheck: (manual: boolean) => Promise; + /** Release shown in the in-app update dialog (manual checks only). */ + pendingRelease: ReleaseInfo | null; + dismissUpdateDialog: () => void; + /** Opens the APK direct link on Android, the release page elsewhere. */ + openReleaseDownload: (release: ReleaseInfo) => void; +}; + +/** + * Update check against the repo's latest GitHub release. The automatic check + * runs on mount at most once a day (update-check-store throttles it; failures + * don't count and simply retry on the next visit). Manual checks (tapping the + * version row) always run and report every outcome: a dialog for a new + * release or a failure, a toast when already up to date. + * + * The last successful check is persisted, so the "update available" hint is + * seeded on mount and survives restarts even before the fresh fetch resolves. + */ +export function useUpdateCheck(currentVersion: string): UseUpdateCheck { + const [checking, setChecking] = React.useState(false); + const [pendingRelease, setPendingRelease] = React.useState(null); + const [newerRelease, setNewerRelease] = React.useState(() => { + // Only seed when the persisted find still beats the installed version — + // after updating the app the stale hint must not come back. + const known = getLastKnownRelease(); + return known && isNewerVersion(known.version, currentVersion) ? known : null; + }); + const inFlight = React.useRef(false); + + const runCheck = React.useCallback( + async (manual: boolean) => { + if (inFlight.current) return; + inFlight.current = true; + setChecking(true); + try { + const release = await fetchLatestRelease(); + recordCheckAt(Date.now()); + // Persist every successful outcome (also clears a stale newer-hint + // once the installed version catches up). + saveLastKnownRelease(release); + if (isNewerVersion(release.version, currentVersion)) { + setNewerRelease(release); + if (manual) { + setPendingRelease(release); + } + } else { + setNewerRelease(null); + if (manual) { + showMessage({ + message: translate('settings.up_to_date', { version: currentVersion }), + type: 'success', + position: 'center', + duration: 2500, + }); + } + } + } catch (error) { + // Automatic checks stay silent — the next Settings visit retries. + if (manual) { + Alert.alert(translate('settings.update_check_failed'), fetchErrorMessage(error)); + } + } finally { + inFlight.current = false; + setChecking(false); + } + }, + [currentVersion] + ); + + React.useEffect(() => { + if (shouldAutoCheck()) { + runCheck(false); + } + }, [runCheck]); + + /** + * Android prefers the release's direct APK asset (what EAS attaches); + * everything else lands on the human-facing release page. + */ + const openReleaseDownload = React.useCallback((release: ReleaseInfo) => { + const url = + Platform.OS === 'android' ? (release.apkUrl ?? release.releaseUrl) : release.releaseUrl; + Linking.openURL(url).catch(() => { + // Nothing sensible to do when no browser/installer picks it up. + }); + }, []); + + return { + checking, + newerRelease, + /** Release shown in the in-app dialog; set only by manual checks. */ + pendingRelease, + runCheck, + dismissUpdateDialog: () => setPendingRelease(null), + openReleaseDownload, + }; +} + +function translate(key: string, options?: Record): string { + const t = i18n.t.bind(i18n) as (key: string, options?: Record) => string; + return t(key, options ?? {}); +} diff --git a/src/lib/r34/fetch-error.test.ts b/src/lib/r34/fetch-error.test.ts index 5bd6202..231d0c8 100644 --- a/src/lib/r34/fetch-error.test.ts +++ b/src/lib/r34/fetch-error.test.ts @@ -6,7 +6,7 @@ import { showMessage } from 'react-native-flash-message'; import { resources } from '@/lib/i18n/resources'; -import { classifyFetchError, showFetchErrorToast } from './fetch-error'; +import { classifyFetchError, fetchErrorMessage, showFetchErrorToast } from './fetch-error'; const httpError = (status: number) => { const error = new Error(`Failed to fetch https://rule34video.com/x/: ${status}`) as Error & { @@ -38,6 +38,17 @@ describe('classifyFetchError', () => { }); }); +describe('fetchErrorMessage', () => { + it('returns the same localized wording the toast uses, for dialogs', () => { + expect(fetchErrorMessage(httpError(502))).toBe( + resources.en.translation.net.server.replace('{{status}}', '502') + ); + expect(fetchErrorMessage(new Error('Network request failed'))).toBe( + resources.en.translation.net.offline + ); + }); +}); + describe('showFetchErrorToast', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/lib/r34/fetch-error.ts b/src/lib/r34/fetch-error.ts index 91b5e76..3948ba1 100644 --- a/src/lib/r34/fetch-error.ts +++ b/src/lib/r34/fetch-error.ts @@ -38,16 +38,28 @@ const MESSAGE_KEYS: Record = { }; /** - * Small centered toast describing a failed fetch. Messages are classified - * (see `classifyFetchError`) and localized; server/other-HTTP messages carry - * the status code, e.g. "The site is having trouble (502) — try again later". + * The localized message for a classified fetch error (see MESSAGE_KEYS) — + * used by the toast below and by dialogs that need the same wording. */ -export function showFetchErrorToast(error: unknown): void { +export function fetchErrorMessage(error: unknown): string { const kind = classifyFetchError(error); const status = (error as (Error & { status?: number }) | null)?.status; const translate = i18n.t.bind(i18n) as (key: string, options?: Record) => string; - const message = translate(MESSAGE_KEYS[kind], { + return translate(MESSAGE_KEYS[kind], { ...(typeof status === 'number' ? { status: String(status) } : {}), }); - showMessage({ message, type: 'warning', position: 'center', duration: 2500 }); +} + +/** + * Small centered toast describing a failed fetch. Messages are classified + * (see `classifyFetchError`) and localized; server/other-HTTP messages carry + * the status code, e.g. "The site is having trouble (502) — try again later". + */ +export function showFetchErrorToast(error: unknown): void { + showMessage({ + message: fetchErrorMessage(error), + type: 'warning', + position: 'center', + duration: 2500, + }); } diff --git a/src/lib/stores/update-check-store.test.ts b/src/lib/stores/update-check-store.test.ts new file mode 100644 index 0000000..8c89c2a --- /dev/null +++ b/src/lib/stores/update-check-store.test.ts @@ -0,0 +1,42 @@ +jest.mock('@/lib/storage', () => { + const store: Record = {}; + return { + __esModule: true, + getItem: (key: string): T | null => (key in store ? JSON.parse(store[key]) : null), + setItem: (key: string, value: unknown) => { + store[key] = JSON.stringify(value); + }, + }; +}); + +import { AUTO_CHECK_INTERVAL_MS, shouldAutoCheck, useUpdateCheckStore } from './update-check-store'; + +const DAY = AUTO_CHECK_INTERVAL_MS; + +beforeEach(() => { + useUpdateCheckStore.setState({ lastCheckedAt: null }); +}); + +describe('useUpdateCheckStore', () => { + it('records the check time and persists it to storage', () => { + const { getItem } = jest.requireMock('@/lib/storage'); + + useUpdateCheckStore.getState().recordCheck(1000); + + expect(useUpdateCheckStore.getState().lastCheckedAt).toBe(1000); + expect(getItem('update.last_check_at')).toBe(1000); + }); +}); + +describe('shouldAutoCheck', () => { + it('is due before any check ever ran', () => { + expect(shouldAutoCheck()).toBe(true); + }); + + it('is not due within a day of the last check', () => { + useUpdateCheckStore.getState().recordCheck(1_000_000); + + expect(shouldAutoCheck(1_000_000 + DAY - 1)).toBe(false); + expect(shouldAutoCheck(1_000_000 + DAY)).toBe(true); + }); +}); diff --git a/src/lib/stores/update-check-store.ts b/src/lib/stores/update-check-store.ts new file mode 100644 index 0000000..f15548b --- /dev/null +++ b/src/lib/stores/update-check-store.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand'; + +import { getItem, setItem } from '@/lib/storage'; + +const LAST_CHECK_AT_KEY = 'update.last_check_at'; + +/** Automatic checks run at most once per day; manual checks always run. */ +export const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +type UpdateCheckState = { + /** Epoch ms of the last completed successful check (null before the first). */ + lastCheckedAt: number | null; + recordCheck: (checkedAt: number) => void; +}; + +/** + * Tracks when the update check last ran, persisted to MMKV so the daily + * throttle survives restarts (the store seeds from storage on creation). + */ +export const useUpdateCheckStore = create((set) => ({ + lastCheckedAt: getItem(LAST_CHECK_AT_KEY) ?? null, + + recordCheck: (checkedAt) => { + setItem(LAST_CHECK_AT_KEY, checkedAt); + set({ lastCheckedAt: checkedAt }); + }, +})); + +/** True when the last check is missing or older than the daily interval. */ +export function shouldAutoCheck(now: number = Date.now()): boolean { + const { lastCheckedAt } = useUpdateCheckStore.getState(); + return lastCheckedAt === null || now - lastCheckedAt >= AUTO_CHECK_INTERVAL_MS; +} + +/** Records a completed check from non-react callers (e.g. the check hook). */ +export function recordCheckAt(checkedAt: number): void { + useUpdateCheckStore.getState().recordCheck(checkedAt); +} diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts new file mode 100644 index 0000000..a8712d4 --- /dev/null +++ b/src/lib/updater.test.ts @@ -0,0 +1,125 @@ +jest.mock('@/lib/storage', () => { + const store: Record = {}; + return { + __esModule: true, + getItem: (key: string): T | null => (key in store ? JSON.parse(store[key]) : null), + setItem: (key: string, value: unknown) => { + store[key] = JSON.stringify(value); + }, + }; +}); + +import { fetchLatestRelease, isNewerVersion } from './updater'; + +const jsonResponse = (body: unknown, status = 200) => + ({ ok: status >= 200 && status < 300, status, json: async () => body }) as unknown as Response; + +describe('isNewerVersion', () => { + it('compares numerically per component', () => { + expect(isNewerVersion('0.4.0', '0.3.0')).toBe(true); + expect(isNewerVersion('0.3.0', '0.3.0')).toBe(false); + expect(isNewerVersion('0.3.0', '0.4.0')).toBe(false); + expect(isNewerVersion('0.10.0', '0.9.0')).toBe(true); // not lexicographic + expect(isNewerVersion('1.0', '0.99.9')).toBe(true); + }); + + it('treats malformed components as zero', () => { + expect(isNewerVersion('abc', '0.0.1')).toBe(false); + expect(isNewerVersion('0.0.2', '0.0.x')).toBe(true); + }); +}); + +describe('fetchLatestRelease', () => { + const realFetch = global.fetch; + const mockFetch = (r: Response) => { + global.fetch = jest.fn(() => r) as unknown as typeof fetch; + }; + + afterEach(() => { + global.fetch = realFetch; + jest.restoreAllMocks(); + }); + + it('normalizes the tag, keeps the title and flattens markdown links', async () => { + mockFetch( + jsonResponse({ + tag_name: 'v0.4.0', + name: 'v0.4.0', + body: '## What\u2019s Changed\n* fix by [@me](https://github.com/me) in [PR](https://github.com/x)', + }) + ); + + const release = await fetchLatestRelease(); + + expect(release.version).toBe('0.4.0'); + expect(release.title).toBe('v0.4.0'); + expect(release.notes).toContain('fix by @me in PR'); + expect(release.notes).not.toContain('](http'); + }); + + it('normalizes CRLF bodies and strips headings/emphasis/list markers', async () => { + // GitHub release bodies really use \r\n; without normalization the stray + // \r glues lines together on Android. + mockFetch( + jsonResponse({ + tag_name: 'v0.4.0', + body: "## What's Changed\r\n\r\n* fix one\r\n* fix two\r\n\r\nsome **bold** and `code`\r\n", + }) + ); + + const release = await fetchLatestRelease(); + + expect(release.notes).not.toContain('\r'); + expect(release.notes.split('\n')).toEqual([ + "What's Changed", + '', + '• fix one', + '• fix two', + '', + 'some bold and code', + ]); + }); + + it('throws with the HTTP status attached for non-OK responses', async () => { + mockFetch(jsonResponse({ message: 'rate limited' }, 403)); + + await expect(fetchLatestRelease()).rejects.toMatchObject({ status: 403 }); + }); + + it('extracts the release page URL, APK asset link/size and publish date', async () => { + mockFetch( + jsonResponse({ + tag_name: 'v0.4.0', + html_url: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0', + published_at: '2026-09-05T10:00:00Z', + assets: [ + { name: 'source.zip', browser_download_url: 'https://x/source.zip' }, + { name: 'r34-0.4.0.apk', size: 42000000, browser_download_url: 'https://x/r34.apk' }, + ], + }) + ); + + const release = await fetchLatestRelease(); + + expect(release.releaseUrl).toBe('https://github.com/ghostcoder42/r34/releases/tag/v0.4.0'); + expect(release.apkUrl).toBe('https://x/r34.apk'); + expect(release.apkSize).toBe(42000000); + expect(release.publishedAt).toBe('2026-09-05T10:00:00Z'); + }); + + it('keeps apkUrl null when the release ships no APK asset', async () => { + mockFetch(jsonResponse({ tag_name: 'v0.4.0', assets: [] })); + + const release = await fetchLatestRelease(); + + expect(release.apkUrl).toBeNull(); + expect(release.apkSize).toBeUndefined(); + expect(release.releaseUrl).toBe('https://github.com/ghostcoder42/r34/releases/latest'); + }); + + it('rejects payloads without a numeric dotted tag', async () => { + mockFetch(jsonResponse({ tag_name: 'nightly', name: 'Nightly', body: 'x' })); + + await expect(fetchLatestRelease()).rejects.toThrow('Unexpected release payload'); + }); +}); diff --git a/src/lib/updater.ts b/src/lib/updater.ts new file mode 100644 index 0000000..cd942dd --- /dev/null +++ b/src/lib/updater.ts @@ -0,0 +1,165 @@ +import { getItem, setItem } from '@/lib/storage'; + +const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/ghostcoder42/r34/releases/latest'; +const REQUEST_TIMEOUT_MS = 10000; +const MAX_NOTES_LENGTH = 2000; + +export type ReleaseInfo = { + /** Plain version, e.g. "0.4.0" (leading "v" from the git tag stripped). */ + version: string; + /** Release title (np uses the tag, e.g. "v0.4.0"). */ + title: string; + /** Release notes with markdown flattened to plain text. */ + notes: string; + /** Human-facing release page (works on every platform). */ + releaseUrl: string; + /** Direct APK download URL when the release ships one (null otherwise). */ + apkUrl: string | null; + /** APK asset size in bytes when an APK asset exists. */ + apkSize?: number; + /** ISO publication timestamp, shown as a plain date (no Intl needed). */ + publishedAt?: string; +}; + +type GithubReleaseAsset = { name?: unknown; size?: unknown; browser_download_url?: unknown }; +type GithubReleaseJson = { + tag_name?: unknown; + name?: unknown; + published_at?: unknown; + body?: unknown; + html_url?: unknown; + assets?: unknown; +}; + +/** Normalizes the GitHub /releases/latest payload; throws on unusable data. */ +function parseRelease(json: GithubReleaseJson): ReleaseInfo { + const tag = typeof json.tag_name === 'string' ? json.tag_name.replace(/^v/, '').trim() : ''; + if (!tag || !/^\d+(\.\d+)*$/.test(tag)) { + throw new Error('Unexpected release payload'); + } + + const releaseUrl = + typeof json.html_url === 'string' && json.html_url + ? json.html_url + : 'https://github.com/ghostcoder42/r34/releases/latest'; + + let apkUrl: string | null = null; + let apkSize: number | undefined; + if (Array.isArray(json.assets)) { + for (const asset of json.assets as GithubReleaseAsset[]) { + if ( + typeof asset.name === 'string' && + asset.name.endsWith('.apk') && + typeof asset.browser_download_url === 'string' + ) { + apkUrl = asset.browser_download_url; + if (typeof asset.size === 'number' && asset.size > 0) apkSize = asset.size; + break; + } + } + } + + const publishedAt = + typeof json.published_at === 'string' && json.published_at ? json.published_at : undefined; + + return { + version: tag, + title: (typeof json.name === 'string' && json.name.trim()) || `v${tag}`, + notes: cleanNotes(typeof json.body === 'string' ? json.body : ''), + releaseUrl, + apkUrl, + apkSize, + publishedAt, + }; +} + +/** + * Fetches the latest published GitHub release. The repo is public, so the + * unauthenticated REST endpoint works (60 req/h rate limit is plenty for a + * check per Settings visit). Errors carry the HTTP status where applicable, + * so callers can reuse the classified fetch-error wording. + */ +export async function fetchLatestRelease(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(GITHUB_LATEST_RELEASE_URL, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'r34-app', + }, + signal: controller.signal, + }); + if (!response.ok) { + const error = new Error(`Failed to fetch release info: ${response.status}`) as Error & { + status?: number; + }; + error.status = response.status; + throw error; + } + return parseRelease(await response.json()); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timed out: release info'); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +/** Numeric component-wise comparison; true when `latest` is newer than `current`. */ +export function isNewerVersion(latest: string, current: string): boolean { + const parse = (v: string) => + v + .split('.') + .map((part) => Number.parseInt(part, 10)) + .map((n) => (Number.isNaN(n) ? 0 : n)); + const a = parse(latest); + const b = parse(current); + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x !== y) return x > y; + } + return false; +} + +/** + * Flatten release-note markdown to plain text (dialogs show no formatting). + * GitHub bodies use CRLF — normalize first, or the stray \r glues lines + * together on Android. Heading regex uses [ \t]* (not \s*) so it doesn't eat + * the line break and glue the heading to the body. + */ +function cleanNotes(body: string): string { + const cleaned = body + .replace(/\r\n?/g, '\n') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/^#{1,6}[ \t]*/gm, '') + .replace(/(\*\*|__|`+)/g, '') + .replace(/^[ \t]*[-*+][ \t]+/gm, '• ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + return truncateText(cleaned, MAX_NOTES_LENGTH); +} + +/** Clamp a text to `max` characters with an ellipsis. */ +function truncateText(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max).trimEnd()}…`; +} + +const LAST_KNOWN_KEY = 'update.last_known_release'; + +/** + * Persists the outcome of the last successful check so the "update available" + * hint survives an app restart — Settings can show it before/without a fresh + * fetch (e.g. offline). + */ +export function saveLastKnownRelease(release: ReleaseInfo): void { + setItem(LAST_KNOWN_KEY, release); +} + +export function getLastKnownRelease(): ReleaseInfo | null { + return getItem(LAST_KNOWN_KEY); +} diff --git a/src/translations/en.json b/src/translations/en.json index f423c78..a395c8c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "Delete all downloaded videos? This cannot be undone.", "clear_downloads_title": "Clear Downloads", "data_storage": "Data & Storage", - "manage_links": "Manage", "general": "General", "github": "Github", "hide_preview": "Hide Preview", @@ -40,6 +39,7 @@ "language": "Language", "links": "Links", "logout": "Logout", + "manage_links": "Manage", "minute_1": "1 minute", "minutes_5": "5 minutes", "more": "More", @@ -50,8 +50,10 @@ "privacy": "Privacy Policy", "privacy_security": "Privacy & Security", "rate": "Rate", - "seconds_5": "5 seconds", + "release_date": "Released", + "release_notes": "What's new:", "seconds_30": "30 seconds", + "seconds_5": "5 seconds", "share": "Share", "show_all": "Show All", "support": "Support", @@ -64,6 +66,12 @@ "title": "Theme" }, "title": "Settings", + "up_to_date": "Up to date (v{{version}})", + "update_available": "Update available", + "update_available_msg": "Version {{version}} is available (current: {{current}}). Download it now?", + "update_available_title": "New version v{{version}}", + "update_check_failed": "Update check failed", + "update_now": "Update now", "version": "Version", "website": "Website", "wifi_only": "WiFi Only Downloads" diff --git a/src/translations/es.json b/src/translations/es.json index ba08085..2d8c2a3 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "¿Eliminar todos los videos descargados? Esto no se puede deshacer.", "clear_downloads_title": "Borrar descargas", "data_storage": "Datos y almacenamiento", - "manage_links": "Gestionar", "general": "General", "github": "Github", "hide_preview": "Ocultar vista previa", @@ -40,6 +39,7 @@ "language": "Idioma", "links": "Enlaces", "logout": "Cerrar sesión", + "manage_links": "Gestionar", "minute_1": "1 minuto", "minutes_5": "5 minutos", "more": "Más", @@ -50,8 +50,10 @@ "privacy": "Política de privacidad", "privacy_security": "Privacidad y seguridad", "rate": "Calificar", - "seconds_5": "5 segundos", + "release_date": "Publicado", + "release_notes": "Novedades:", "seconds_30": "30 segundos", + "seconds_5": "5 segundos", "share": "Compartir", "show_all": "Mostrar todo", "support": "Soporte", @@ -64,6 +66,12 @@ "title": "Tema" }, "title": "Ajustes", + "up_to_date": "Actualizado (v{{version}})", + "update_available": "Actualización disponible", + "update_available_msg": "La versión {{version}} está disponible (actual: {{current}}). ¿Descargarla ahora?", + "update_available_title": "Nueva versión v{{version}}", + "update_check_failed": "Error al buscar actualizaciones", + "update_now": "Actualizar ahora", "version": "Versión", "website": "Sitio web", "wifi_only": "Descargas solo por Wi-Fi" diff --git a/src/translations/ja.json b/src/translations/ja.json index d92d6e1..eaa8195 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "すべてのダウンロード済み動画を削除しますか?この操作は元に戻せません。", "clear_downloads_title": "ダウンロードを削除", "data_storage": "データとストレージ", - "manage_links": "管理", "general": "一般", "github": "Github", "hide_preview": "プレビューを隠す", @@ -40,6 +39,7 @@ "language": "言語", "links": "リンク", "logout": "ログアウト", + "manage_links": "管理", "minute_1": "1 分", "minutes_5": "5 分", "more": "その他", @@ -50,8 +50,10 @@ "privacy": "プライバシーポリシー", "privacy_security": "プライバシーとセキュリティ", "rate": "評価", - "seconds_5": "5 秒", + "release_date": "リリース日", + "release_notes": "更新内容:", "seconds_30": "30 秒", + "seconds_5": "5 秒", "share": "共有", "show_all": "すべて表示", "support": "サポート", @@ -64,6 +66,12 @@ "title": "テーマ" }, "title": "設定", + "up_to_date": "最新バージョンです(v{{version}})", + "update_available": "新しいバージョンがあります", + "update_available_msg": "新しいバージョン {{version}} が利用可能です(現在 {{current}})。今すぐダウンロードしますか?", + "update_available_title": "新しいバージョン v{{version}}", + "update_check_failed": "アップデートの確認に失敗しました", + "update_now": "今すぐ更新", "version": "バージョン", "website": "ウェブサイト", "wifi_only": "Wi-Fi のみダウンロード" diff --git a/src/translations/ko.json b/src/translations/ko.json index f6fe085..183de2d 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "다운로드한 모든 동영상을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "clear_downloads_title": "다운로드 삭제", "data_storage": "데이터 및 저장공간", - "manage_links": "관리", "general": "일반", "github": "Github", "hide_preview": "미리보기 숨기기", @@ -40,6 +39,7 @@ "language": "언어", "links": "링크", "logout": "로그아웃", + "manage_links": "관리", "minute_1": "1분", "minutes_5": "5분", "more": "더보기", @@ -50,8 +50,10 @@ "privacy": "개인정보 처리방침", "privacy_security": "개인정보 및 보안", "rate": "평가", - "seconds_5": "5초", + "release_date": "출시일", + "release_notes": "업데이트 내용:", "seconds_30": "30초", + "seconds_5": "5초", "share": "공유", "show_all": "모두 표시", "support": "지원", @@ -64,6 +66,12 @@ "title": "테마" }, "title": "설정", + "up_to_date": "최신 버전입니다(v{{version}})", + "update_available": "새 버전이 있습니다", + "update_available_msg": "새 버전 {{version}}이(가) 출시되었습니다(현재 {{current}}). 지금 다운로드할까요?", + "update_available_title": "새 버전 v{{version}}", + "update_check_failed": "업데이트 확인에 실패했습니다", + "update_now": "지금 업데이트", "version": "버전", "website": "웹사이트", "wifi_only": "Wi-Fi 전용 다운로드" diff --git a/src/translations/pt.json b/src/translations/pt.json index 88754c0..a9f2f7b 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "Excluir todos os vídeos baixados? Isso não pode ser desfeito.", "clear_downloads_title": "Limpar downloads", "data_storage": "Dados e armazenamento", - "manage_links": "Gerenciar", "general": "Geral", "github": "Github", "hide_preview": "Ocultar pré-visualização", @@ -40,6 +39,7 @@ "language": "Idioma", "links": "Links", "logout": "Sair", + "manage_links": "Gerenciar", "minute_1": "1 minuto", "minutes_5": "5 minutos", "more": "Mais", @@ -50,8 +50,10 @@ "privacy": "Política de privacidade", "privacy_security": "Privacidade e segurança", "rate": "Avaliar", - "seconds_5": "5 segundos", + "release_date": "Publicado", + "release_notes": "Novidades:", "seconds_30": "30 segundos", + "seconds_5": "5 segundos", "share": "Compartilhar", "show_all": "Mostrar tudo", "support": "Suporte", @@ -64,6 +66,12 @@ "title": "Tema" }, "title": "Configurações", + "up_to_date": "Atualizado (v{{version}})", + "update_available": "Atualização disponível", + "update_available_msg": "A versão {{version}} está disponível (atual: {{current}}). Baixar agora?", + "update_available_title": "Nova versão v{{version}}", + "update_check_failed": "Falha ao verificar atualizações", + "update_now": "Atualizar agora", "version": "Versão", "website": "Site", "wifi_only": "Downloads somente no Wi-Fi" diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json index 4b00965..ea34803 100644 --- a/src/translations/zh-TW.json +++ b/src/translations/zh-TW.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "刪除所有已下載的影片?此操作無法復原。", "clear_downloads_title": "清除下載", "data_storage": "資料與儲存", - "manage_links": "管理", "general": "一般", "github": "Github", "hide_preview": "隱藏預覽", @@ -40,6 +39,7 @@ "language": "語言", "links": "連結", "logout": "登出", + "manage_links": "管理", "minute_1": "1 分鐘", "minutes_5": "5 分鐘", "more": "更多", @@ -50,8 +50,10 @@ "privacy": "隱私權政策", "privacy_security": "隱私與安全", "rate": "評分", - "seconds_5": "5 秒", + "release_date": "發布於", + "release_notes": "更新說明:", "seconds_30": "30 秒", + "seconds_5": "5 秒", "share": "分享", "show_all": "顯示全部", "support": "支援", @@ -64,6 +66,12 @@ "title": "主題" }, "title": "設定", + "up_to_date": "已是最新版本(v{{version}})", + "update_available": "有新版本可用", + "update_available_msg": "新版本 {{version}} 已發布(目前 {{current}}),是否前往下載?", + "update_available_title": "發現新版本 v{{version}}", + "update_check_failed": "檢查更新失敗", + "update_now": "立即更新", "version": "版本", "website": "網站", "wifi_only": "僅 Wi-Fi 下載" diff --git a/src/translations/zh.json b/src/translations/zh.json index 860034d..8d161f8 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "删除所有已下载的视频?此操作无法撤销。", "clear_downloads_title": "清除下载", "data_storage": "数据与存储", - "manage_links": "管理", "general": "通用", "github": "Github", "hide_preview": "隐藏预览", @@ -40,6 +39,7 @@ "language": "语言", "links": "链接", "logout": "退出登录", + "manage_links": "管理", "minute_1": "1 分钟", "minutes_5": "5 分钟", "more": "更多", @@ -50,8 +50,10 @@ "privacy": "隐私政策", "privacy_security": "隐私与安全", "rate": "评分", - "seconds_5": "5 秒", + "release_date": "发布于", + "release_notes": "更新说明:", "seconds_30": "30 秒", + "seconds_5": "5 秒", "share": "分享", "show_all": "显示全部", "support": "支持", @@ -64,6 +66,12 @@ "title": "主题" }, "title": "设置", + "up_to_date": "已是最新版本(v{{version}})", + "update_available": "有新版本可用", + "update_available_msg": "新版本 {{version}} 已发布(当前 {{current}}),是否前往下载?", + "update_available_title": "发现新版本 v{{version}}", + "update_check_failed": "检查更新失败", + "update_now": "立即更新", "version": "版本", "website": "网站", "wifi_only": "仅 WiFi 下载"