goon/mobile/App.tsx
jtrzupek 0281e449fe build(apk): 0.2.0 — expo-font native, runtime 1.1, fonts re-enabled
Option B (rebuild APK) — odblokowuje custom fonty na stałe + sprawia że
przyszłe font-OTA nie crashują.

- runtime 1.0 → 1.1 (app.json + AndroidManifest EXPO_RUNTIME_VERSION): nowy APK
  ma native ExpoFontLoader, więc MUSI mieć inny runtime niż stare instalacje 1.0
  (inaczej font-OTA crashnęłoby stare). 1.0 channel zostaje na d5b87e5c
  (font-stripped) dla starych, 1.1 = nowy APK z fontami.
- version 0.2.0 / versionCode 10 (build.gradle) — in-app updater (/version=0.2.0)
  zaoferuje install starym 0.1.9.
- Fonty przywrócone (useFonts, theme.fonts realne, SceneTile/MoviePosterCard/
  navigation/GoonWordmark fontFamily) — działają bo native jest w APK.
- Build: gradlew assembleRelease (autolinking expo-font, BEZ prebuild — zachowane
  custom native AntiTamper/ApkInstaller), Sentry source-map upload wyłączony
  (SENTRY_DISABLE_AUTO_UPLOAD, brak org/auth — krok poboczny).
- app/main.py /version 0.1.9 → 0.2.0.

ZWERYFIKOWANE na emulatorze: podpis SHA-256 == ALLOWED_APP_SIG_HASH (anti-tamper
OK), ExpoFontLoader w classes3.dex, `ReactNativeJS: Running "main"` bez crasha.
APK live: /static/app-release.apk + goon-v0.2.0.apk + landing webroot.

UWAGA: launcher-icon (native mipmaps) NIE zmienione w tym buildzie — nadal stara
ikona. Nowy oo-icon wymaga regeneracji res/mipmap-* + rebuild (follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:51:32 +02:00

359 lines
15 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 { 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 },
},
});
// 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);
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 (!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>
);
}
// 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);