goon/mobile/App.tsx
jtrzupek e618087eae feat(mobile): "What's new" popup after OTA updates
After an OTA bundle is applied, show a one-time popup listing recent changes. The
changelog ships in the bundle (mobile/src/changelog.ts), so it is always in sync with
the code that just arrived. WhatsNewModal compares the newest entry id against the last
one seen (SecureStore); shows unseen entries, marks seen on dismiss, and stays quiet
until the next update adds an entry. First run shows only the newest entry (no history
dump). Mounted over the navigator when signed in.

Each OTA publish should prepend a new entry at the top of CHANGELOG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:41:54 +02:00

389 lines
16 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 { 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);
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.
// TEMP 2026-06-02: wyłączone żeby umożliwić debug na emulatorze (screencap +
// playback verification — FLAG_SECURE czyni screenshoty czarnymi). Jan jest na
// razie jedynym userem. PRZYWRÓCIĆ `true` przed szerszą dystrybucją.
const SCREEN_CAPTURE_PROTECTION = false;
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;
(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}>
<SceneActionsProvider>
<AppNavigator
client={client}
appVersion={APP_VERSION}
onLogout={async () => {
await clearCredentials();
setClient(null);
queryClient.clear();
}}
/>
<WhatsNewModal />
</SceneActionsProvider>
</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);