goon/mobile/App.tsx
goon-foss 0949091f78 fix(ui): pasek zakladek naprawde plynie - zakladki jako stan, nie trasy
Zgloszenie: "zamiast slicznych animowanych przesuwajacych sie elementow mam
statyczna kropke". Dwie niezalezne przyczyny, obie konieczne do naprawy:

1. Animacja NIE MIALA FIZYCZNEJ MOZLIWOSCI sie wykonac. Kazda zakladka byla
   osobnym ekranem native stacka, a przelaczenie robilo navigation.replace(t),
   czyli wymienialo caly ekran razem z naglowkiem. LiquidTabs montowal sie od
   nowa z kropla juz na docelowej pozycji (pierwsze ustawienie celowo bez
   animacji). Fix: jeden HomeScreen, zakladka to stan lokalny, pasek przezywa
   przelaczenie. key={tab} na tresci zachowuje dotychczasowy reset stanu.

2. Pasek byl NIEWIDZIALNY: wypelnienie theme.card na naglowku o tle theme.card.
   Z calego "roztapiania" zostawala sama akcentowa kropka. Fix: naglowek na
   theme.bg.

Do tego drobne: easing Easing.out(cubic) 260ms (wczesniej bez znaczenia, bo kod
nigdy nie dzialal); BugReportFAB czyta zakladke z params.tab (nazwa trasy to
teraz zawsze 'Scenes'); ikony naglowka ujednolicone na wszystkich zakladkach;
App.tsx: blad ladowania fontow przestaje wieszac boot na wiecznym spinnerze
(fail-open na system font) - obnazone przez rozjazd JS/natywki w dev buildzie.

Trasy Movies/Sites/JAV/Anime wyrejestrowane (nic poza paskiem do nich nie
nawigowalo); typy w RootStackParamList zostaja dla useNavigation.

Zweryfikowane nagraniem ekranu emulatora klatka po klatce: kropla plynie
Scenes->Sites i JAV->Anime z wedrujacym wglebieniem, pasek widoczny, tresc
i przycisk Performers/Tags zgodne z zakladka.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:59:24 +02:00

449 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// MUST be the first import for react-navigation 7 + reanimated/gesture-handler
// in release builds. Otherwise Hermes-bundled APK boots to a white screen.
import 'react-native-gesture-handler';
// Guard na Alert.alert (AppState==='active'), kill crasha "Adding window failed" /
// DeadObjectException gdy dialog leci na martwym oknie (Sentry ddec9d6b). Side-effect.
import './src/lib/alertGuard';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Sentry from '@sentry/react-native';
import Constants from 'expo-constants';
import { registerRootComponent } from 'expo';
import { useFonts } from 'expo-font';
import { Text as RNText } from 'react-native';
import * as ScreenCapture from 'expo-screen-capture';
import { StatusBar } from 'expo-status-bar';
import * as Updates from 'expo-updates';
import {
apkInstallerAvailable,
canRequestInstall,
installApk,
openInstallPermissionSettings,
} from './src/native/apkInstaller';
import React, { useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Alert, AppState, AppStateStatus, Linking, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GoonClient } from './src/api';
import { ClientProvider } from './src/ClientContext';
import { PreferencesProvider } from './src/PreferencesContext';
import { SceneActionsProvider } from './src/SceneActionsContext';
import { OnboardingModal } from './src/components/OnboardingModal';
import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
import { APP_VERSION } from './src/lib/appVersion';
import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend';
import { getSettings as getLockSettings } from './src/lib/applock';
import { AppNavigator } from './src/navigation';
import { AgeGateScreen } from './src/screens/AgeGateScreen';
import { AppLockScreen } from './src/screens/AppLockScreen';
import { LoginScreen } from './src/screens/LoginScreen';
import { clearCredentials, isLegacyAdopted, loadCredentials, markLegacyAdopted } from './src/storage';
import { theme } from './src/theme';
// Sentry: init przed registerRootComponent. Pusty DSN → SDK no-op (devel build bez
// crash reportingu). DSN czytamy z `EXPO_PUBLIC_SENTRY_DSN` (Expo SDK 49+ auto-injectuje
// env var do bundla przy buildzie), z fallback na app.json `extra.sentryDsn` (legacy).
// Lokalnie: ustaw w `mobile/.env` (gitignored). CI: GitHub Secret `SENTRY_DSN` →
// workflow exportuje jako `EXPO_PUBLIC_SENTRY_DSN` przed gradle build.
const SENTRY_DSN =
process.env.EXPO_PUBLIC_SENTRY_DSN ||
(Constants.expoConfig?.extra?.sentryDsn as string | undefined);
const SENTRY_ENV =
process.env.EXPO_PUBLIC_SENTRY_ENVIRONMENT ||
(Constants.expoConfig?.extra?.sentryEnvironment as string | undefined) ||
'production';
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
environment: SENTRY_ENV,
// 0.1 = 10% sesji ma full performance trace. Free tier ma 10k transactions/mies,
// przy 1 użytkowniku to wieczność. Errory są zawsze 100%.
tracesSampleRate: 0.1,
// Lokalna wersja appki, dystyngwuje builds w Sentry release filter. expo-constants
// czyta `version` z `app.json` (a nie z package.json), więc tutaj `expoConfig.version`.
release: `goon-mobile@${Constants.expoConfig?.version ?? '0.0.0'}`,
// Filtr szumu: Android Background ANR (łapany przez AppExitInfo) to OS zamrażający
// apkę w TLE i raportujący "nie odpowiada", zero ramek JS/app, nic do naprawy
// (GOON-1D). Dropujemy TYLKO background; foreground ANR zostaje (realny jank).
beforeSend: (event) => {
const isAnr = event.exception?.values?.some(
(v) => v?.type === 'ApplicationNotResponding' || /\bANR\b/.test(v?.value || ''),
);
if (isAnr && event.contexts?.app?.in_foreground === false) return null;
return event;
},
// Boot diagnostic: jeden message przy starcie z tagiem `source:boot` pozwala
// potwierdzić że SDK rzeczywiście wysyła. Jeśli w Sentry nie ma go po starcie
// appki → init nie startuje albo zaprzeszł blockera (network/DNS/uplink).
// sendDefaultPii=false (default), IP / user-agent / cookies nie idą do Sentry.
// attachScreenshot=false, scena thumbnails / video frames nie wyciekną w error reports.
});
// Boot breadcrumb (NIE event). Wcześniej `captureMessage('mobile boot OK', level:info)`
// tworzył osobny event przy KAŻDYM starcie → 171 eventów / 13 userów szumu w dashboardzie
// (GOON-Q). Diagnostyk spełnił rolę, wiemy że SDK wysyła. Breadcrumb zostawia kontekst
// bootu doczepiony do realnych błędów, ale nie zaśmieca listy issue.
Sentry.addBreadcrumb({
category: 'boot',
message: 'mobile boot OK',
level: 'info',
});
}
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 },
},
});
// Globalny default fontu dla całego <Text>, General Sans Regular jako body.
// Komponenty które chcą display/mono nadpisują fontFamily jawnie (bo RN nie
// syntezuje weightów dla custom fontów). Bold-ale-bez-fontFamily tekst zostanie
// Regular weightem General Sans, wciąż distinctive face, akceptowalne dla
// nietkniętych ekranów; high-traffic komponenty mają jawny Semibold.
let _textDefaultApplied = false;
function applyDefaultFont() {
if (_textDefaultApplied) return;
_textDefaultApplied = true;
const T = RNText as unknown as { defaultProps?: { style?: unknown } };
T.defaultProps = T.defaultProps || {};
const prev = T.defaultProps.style;
T.defaultProps.style = [{ fontFamily: 'GeneralSans-Regular' }, prev].filter(Boolean);
}
export default function App() {
const [fontsLoaded, fontsError] = useFonts({
'GeneralSans-Regular': require('./assets/fonts/GeneralSans-Regular.ttf'),
'GeneralSans-Medium': require('./assets/fonts/GeneralSans-Medium.ttf'),
'GeneralSans-Semibold': require('./assets/fonts/GeneralSans-Semibold.ttf'),
'GeistMono-Regular': require('./assets/fonts/GeistMono-Regular.ttf'),
});
// Fail-open: błąd ładowania fontów NIE MOŻE wieszać bootu. `fontsLoaded` zostaje
// false na zawsze przy odrzuconym loadAsync (np. rozjazd JS/natywki w dev build,
// 2026-08-18: 'AppDirectories not found' → wieczny spinner). System font < brak apki.
const fontsSettled = fontsLoaded || fontsError != null;
if (fontsLoaded) applyDefaultFont();
const [hydrated, setHydrated] = useState(false);
const [ageAccepted, setAgeAccepted] = useState(false);
const [client, setClient] = useState<GoonClient | null>(null);
const [bootError, setBootError] = useState<string | null>(null);
const [locked, setLocked] = useState(false);
const [lockReady, setLockReady] = useState(false);
// OTA-check settled, gate'uje ekran PIN, żeby ewentualny reloadAsync (silent OTA)
// wydarzył się PRZED pytaniem o PIN, nie po (inaczej "PIN ×2", user-report mobilism).
const [updateSettled, setUpdateSettled] = useState(false);
const backgroundedAt = useRef<number | null>(null);
useEffect(() => {
(async () => {
try {
const accepted = await isAgeGateAccepted();
setAgeAccepted(accepted);
const creds = await loadCredentials();
if (creds) {
setClient(new GoonClient(creds.baseUrl, creds.apiKey));
} else {
// No stored credentials → auto-connect to the public instance.
// LoginScreen only appears after an explicit "Sign out".
setClient(new GoonClient(DEFAULT_BACKEND_URL, DEFAULT_API_KEY));
}
const lockSettings = await getLockSettings();
if (lockSettings.enabled && lockSettings.hasPin) {
setLocked(true);
}
} catch (e) {
// SecureStore may fail on devices without lockscreen / keychain quirks,
// surface the error instead of silently white-screening.
setBootError(e instanceof Error ? e.message : String(e));
} finally {
setHydrated(true);
setLockReady(true);
}
})();
}, []);
// Jednorazowe przejęcie legacy stanu usera (favorites/progress/blacklisty sprzed
// device-scopingu, bug 2026-06-08). Po pierwszym launchu z nowym bundlem przepinamy
// legacy-shared rows na to urządzenie. Flaga w SecureStore → tylko raz. (Właściciel
// instancji powinien zrelaunchować apkę jako pierwszy, żeby przejąć swoją historię.)
useEffect(() => {
if (!client) return;
(async () => {
try {
if (await isLegacyAdopted()) return;
await client.adoptLegacy();
await markLegacyAdopted();
} catch {
// brak sieci / już przejęte, spróbuje przy następnym launchu (flaga nieustawiona)
}
})();
}, [client]);
// FLAG_SECURE, blocks screenshots and hides app preview from app switcher.
// WŁĄCZONE 2026-06-16: realni userzy (mobilism), NSFW treść nie ma wyciekać do
// Recents/screenshotów (user feedback). Gate na !__DEV__: RELEASE (userzy, też po OTA)
// ma ochronę, DEBUG (expo run:android na emulatorze) jej NIE ma → adb screencap działa
// do wizualnego debugu. Bezpieczne do publikacji (release nadal secure).
const SCREEN_CAPTURE_PROTECTION = !__DEV__;
useEffect(() => {
if (!SCREEN_CAPTURE_PROTECTION) return;
ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {});
return () => {
ScreenCapture.allowScreenCaptureAsync('goon-applock').catch(() => {});
};
}, []);
// Update flow, DWUSTOPNIOWY:
// B) Expo Updates (silent JS bundle): jeśli backend ma nowszy bundle
// manifest dla naszego runtimeVersion, fetch + reload bez ANY dialogu.
// Większość naszych zmian (api.ts, screens, components) to JS, silent.
// A) APK install (PackageInstaller native): jeśli backend `/version` ma
// wersję ZNACZNIE wyższą niż embedded (native bump z nowym Kotlin/manifest),
// pokaż dialog → native installer (1-click "Install update?").
// Fallback: gdy expo-updates SDK niedostępne (dev build, web), używamy starego
// flow z Linking.openURL.
const updateChecked = useRef(false);
useEffect(() => {
if (!client || updateChecked.current) return;
updateChecked.current = true;
// Safety: nie trzymaj "Checking for updates" w nieskończoność gdy OTA call wisi
// (zła sieć). Po 15s odblokuj PIN, normalny check+fetch JS-bundla to ~1-3s.
const safety = setTimeout(() => setUpdateSettled(true), 15000);
(async () => {
// Etap B: silent JS update
if (Updates.isEnabled) {
try {
const upd = await Updates.checkForUpdateAsync();
if (upd.isAvailable) {
await Updates.fetchUpdateAsync();
// Cichy restart, user zobaczy splash na 200ms i jest w nowej wersji.
// Restart następuje TU (przed PIN-em), więc PIN pyta tylko raz po reloadzie.
await Updates.reloadAsync();
return; // reloadAsync nie wraca
}
} catch {
// OTA fail (brak sieci / 204 / TLS pin mismatch), leciemy do A.
}
}
// Brak OTA (lub disabled/fail) → odblokuj ekran PIN/login/browse.
clearTimeout(safety);
setUpdateSettled(true);
// Etap A: APK update prompt (native bump albo Expo Updates disabled)
try {
const out = await client.getServerVersion();
const bundled = APP_VERSION;
if (out.version && out.version !== bundled && out.apk_url) {
// In-app installer dostępny → "Install" zamiast "Download"
if (apkInstallerAvailable) {
const pendingApkUrl = out.apk_url!;
const runInstall = async () => {
try {
await installApk(pendingApkUrl);
// PackageInstaller pokazuje natywny dialog, JS już nie ma co robić.
} catch (e) {
Alert.alert('Install failed', e instanceof Error ? e.message : String(e));
}
};
const tryInstall = async () => {
const granted = await canRequestInstall();
if (granted) {
await runInstall();
return;
}
// Brak permission → wystaw Settings + zarejestruj AppState listener
// który po powrocie do appki (granted=true) automatycznie odpali
// installApk. Bez tego user musi zamknąć i otworzyć dialog Update
// jeszcze raz po toggling permission, zgłaszane w bug-report 97adff93.
const sub = AppState.addEventListener('change', async (next) => {
if (next !== 'active') return;
const now = await canRequestInstall();
if (now) {
sub.remove();
await runInstall();
}
});
// Safety timeout: zdejmij listener po 5 minutach jeśli user nie
// grantnął (uniknie wycieku w przypadku Settings-canceled).
setTimeout(() => sub.remove(), 5 * 60 * 1000);
Alert.alert(
'Install permission',
'Allow installing updates from Goon? Toggle "Allow from this source", then tap back and the install starts automatically.',
[
{ text: 'Cancel', style: 'cancel', onPress: () => sub.remove() },
{ text: 'Open Settings', onPress: () => openInstallPermissionSettings() },
],
);
};
Alert.alert(
'Update available',
`Server: ${out.version}\nApp: ${bundled}\n\nInstall update now?`,
[
{ text: 'Later', style: 'cancel' },
{ text: 'Install', onPress: tryInstall },
],
);
} else {
// Fallback: Chrome download
Alert.alert(
'Update available',
`Server: ${out.version}\nApp: ${bundled}\n\nTap "Download" to install the latest APK.`,
[
{ text: 'Later', style: 'cancel' },
{
text: 'Download',
onPress: () => Linking.openURL(out.apk_url!).catch(() => {}),
},
],
);
}
}
} catch {
// best-effort
}
})();
}, [client]);
// AppState lock-on-background, re-lock if user backgrounded longer than timeout.
useEffect(() => {
const sub = AppState.addEventListener('change', async (next: AppStateStatus) => {
if (next === 'background' || next === 'inactive') {
backgroundedAt.current = Date.now();
return;
}
if (next === 'active') {
const at = backgroundedAt.current;
backgroundedAt.current = null;
if (at === null) return;
const settings = await getLockSettings();
if (!(settings.enabled && settings.hasPin)) return;
const elapsed = Math.floor((Date.now() - at) / 1000);
if (elapsed >= settings.timeoutSeconds) {
setLocked(true);
}
}
});
return () => sub.remove();
}, []);
if (!fontsSettled || !hydrated || !lockReady) {
return (
<View style={{ flex: 1, backgroundColor: theme.bg, justifyContent: 'center' }}>
<ActivityIndicator color={theme.fg} />
</View>
);
}
// Age gate takes precedence over everything else, must be accepted at the
// current ToS version before any UI (lock, login, browse) is reachable.
if (!ageAccepted) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" />
<AgeGateScreen onAccept={() => setAgeAccepted(true)} />
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
// PIN dopiero PO sprawdzeniu OTA, inaczej user wpisuje PIN, w tle ściąga się
// update, reloadAsync restartuje i PIN pyta znowu ("PIN ×2", user-report mobilism).
// Krótki "Checking for updates" zamiast od razu PIN; jeśli jest OTA, restart
// nastąpi tu, przed PIN-em. Dotyczy tylko zablokowanych (bez locka → prosto do browse).
if (locked && !updateSettled) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" />
<View
style={{
flex: 1,
backgroundColor: theme.bg,
justifyContent: 'center',
alignItems: 'center',
}}
>
<ActivityIndicator color={theme.fg} />
<RNText style={{ color: theme.muted, marginTop: 14, fontSize: 13 }}>
Checking for updates…
</RNText>
</View>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
// App lock takes precedence over everything (even login). Refresh settings every
// unlock so a freshly-disabled lock doesn't bounce back next background.
if (locked) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" />
<AppLockScreen
onUnlock={() => setLocked(false)}
onLogout={async () => {
await clearCredentials();
setClient(null);
queryClient.clear();
setLocked(false);
}}
/>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
return (
<ErrorBoundary>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<StatusBar style="light" />
{bootError ? (
<View style={{ flex: 1, backgroundColor: theme.bg, padding: 20 }}>
<View style={{ marginTop: 80 }}>
<ActivityIndicator color={theme.bad} />
</View>
</View>
) : null}
{client ? (
<ClientProvider client={client}>
<PreferencesProvider>
<SceneActionsProvider>
<AppNavigator
client={client}
appVersion={APP_VERSION}
onLogout={async () => {
await clearCredentials();
setClient(null);
queryClient.clear();
}}
/>
<WhatsNewModal />
<OnboardingModal />
</SceneActionsProvider>
</PreferencesProvider>
</ClientProvider>
) : (
<LoginScreen
onAuthenticated={(baseUrl, apiKey) => setClient(new GoonClient(baseUrl, apiKey))}
/>
)}
</QueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
</ErrorBoundary>
);
}
// `package.json:main` points to this file directly, so we must register the
// root component ourselves (the standard `expo/AppEntry` boilerplate is bypassed).
// Without this line release builds load the bundle but throw
// `Invariant Violation: "main" has not been registered` and white-screen.
//
// Sentry.wrap() owija App w error boundary + adds touch event tracking, standardowy
// pattern z docs Sentry RN. No-op gdy DSN pusty.
registerRootComponent(SENTRY_DSN ? Sentry.wrap(App) : App);