// 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'; 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 — 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] = 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'), }); if (fontsLoaded) applyDefaultFont(); const [hydrated, setHydrated] = useState(false); const [ageAccepted, setAgeAccepted] = useState(false); const [client, setClient] = useState(null); const [bootError, setBootError] = useState(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(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). Bundle OTA jest wspólny, więc to dotyczy // też emulatora — na czas debugu (adb screencap) flipnij tymczasowo na false // lokalnie i NIE publikuj tej zmiany. const SCREEN_CAPTURE_PROTECTION = true; 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 — 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 (!fontsLoaded || !hydrated || !lockReady) { return ( ); } // 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 ( setAgeAccepted(true)} /> ); } // 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 ( Checking for updates… ); } // 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 ( setLocked(false)} onLogout={async () => { await clearCredentials(); setClient(null); queryClient.clear(); setLocked(false); }} /> ); } return ( {bootError ? ( ) : null} {client ? ( { await clearCredentials(); setClient(null); queryClient.clear(); }} /> ) : ( setClient(new GoonClient(baseUrl, apiKey))} /> )} ); } // `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);