goon/mobile/App.tsx
jtrzupek 00f4779abe feat(mobile): column toggle, duration filter, saved searches, screen protection (mobilism feedback)
Batch from user feedback: (1) Grid columns 1/2/3 setting (PreferencesContext, persisted) across all scene grids — default 2 was too small on phones. (2) Min-duration filter chips (5/10/20/30+ min) to hide ad-clips. (3) Saved-search chips + Save button (backed by /saved-searches). (4) Re-enabled screen-capture protection (Recents hide + screenshot block) for distributed users — verified active on emulator (screencap returns 0 bytes). (5) 'Checking for updates' gate before the PIN screen so a background OTA restart no longer causes a double PIN prompt. Changelog entry added. Published OTA runtime 1.1 (a9620b12).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:52:27 +02:00

430 lines
18 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';
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'}`,
// 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);