Android Background ANRs captured via AppExitInfo (GOON-1D) are OS-side noise: the OS freezes a backgrounded app and reports it as not-responding, with zero JS/app frames and nothing to fix. beforeSend now drops events that are ANRs (ApplicationNotResponding) AND backgrounded (contexts.app.in_foreground === false). Foreground ANRs are kept (those can be real jank). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
440 lines
19 KiB
TypeScript
440 lines
19 KiB
TypeScript
// 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 { 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] = 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<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). 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 (
|
||
<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 />
|
||
</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);
|