Mobile / OTA: - Enable Expo Updates (app.json + AndroidManifest) → api.goon-foss.org - Bump 0.1.6 → 0.1.9 (build.gradle, app.json, appVersion.ts, main.py /version) - backend.ts: default public backend auto-connect (no manual login) WebView fallback fix (PlayerScreen INJECTED_JS): - Auto-dismiss cookie/consent gates (hqporner et al. blocked kt_player init) - Context-scoped: only clicks consent buttons inside cookie/gdpr containers - Retry window for <source>.src polling raised 5→15 ticks (post-dismiss init) Resolver: - Series-position + modifier mismatch detector (Episode 2≠4, BTS/unedited) → composite_score hard-reject / cap; wired into scene_score + bulk_dedup - aggregator-mode candidate query: LIMIT 500 + title-match ordering Connectors: - porndoe.com browse scraper (JSON-LD VideoObject) — theporndude audit pilot landing: APK links → goon-v0.1.9.apk Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
334 lines
13 KiB
TypeScript
334 lines
13 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 * 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 { 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, loadCredentials } 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.
|
|
});
|
|
// Capture jednorazowo przy bundle load (przed `App` render). Sentry RN buforuje
|
|
// events do flush przy next tick — wystartuje równo z bridge ready.
|
|
Sentry.captureMessage('mobile boot OK', {
|
|
level: 'info',
|
|
tags: { source: 'boot' },
|
|
});
|
|
}
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 },
|
|
},
|
|
});
|
|
|
|
export default function App() {
|
|
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);
|
|
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);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
// FLAG_SECURE — blocks screenshots and hides app preview from app switcher.
|
|
useEffect(() => {
|
|
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;
|
|
(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.
|
|
await Updates.reloadAsync();
|
|
return; // reloadAsync nie wraca
|
|
}
|
|
} catch {
|
|
// OTA fail (brak sieci / 204 / TLS pin mismatch) — leciemy do A.
|
|
}
|
|
}
|
|
// 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 (!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>
|
|
);
|
|
}
|
|
|
|
// 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}>
|
|
<AppNavigator
|
|
client={client}
|
|
appVersion={APP_VERSION}
|
|
onLogout={async () => {
|
|
await clearCredentials();
|
|
setClient(null);
|
|
queryClient.clear();
|
|
}}
|
|
/>
|
|
</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);
|