diff --git a/.env.example b/.env.example index 487d67d..1ceaf7d 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,4 @@ EXPO_PUBLIC_APP_ENV=production # Feature Flags EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS=true +EXPO_PUBLIC_STORYBOOK=false diff --git a/.husky/pre-commit b/.husky/pre-commit index d24fdfc..2312dc5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - npx lint-staged diff --git a/App.tsx b/App.tsx index 624f73e..be2b57c 100644 --- a/App.tsx +++ b/App.tsx @@ -1,41 +1,49 @@ -import './src/utils/assetInlinePolyfill'; +import * as Font from 'expo-font'; +import { ExpoRoot } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import React, { useEffect, useRef } from 'react'; -import { Alert, AppState, AppStateStatus, InteractionManager, LogBox } from 'react-native'; +import { Alert, AppState, AppStateStatus, LogBox } from 'react-native'; +import StorybookUI from './.rnstorybook'; import './global.css'; - -import * as Font from 'expo-font'; -import * as SplashScreen from 'expo-splash-screen'; import { ErrorBoundary } from './src/components/common/ErrorBoundary'; +import { requireEnvVariables } from './src/config/env'; import { initializeLogging } from './src/config/logging'; import { AuthProvider, useAdaptiveTheme } from './src/hooks'; -import AppNavigator from './src/navigation/AppNavigator'; -import { warmCriticalCaches } from './src/services/cacheWarming'; +import { setupNotificationNavigation } from './src/navigation/linking'; +import apiClient from './src/services/api/axios.config'; +import { requestQueue } from './src/services/api/requestQueue'; +import { crashReportingService } from './src/services/crashReporting'; import { mobileAuthService } from './src/services/mobileAuth'; +import { + addNotificationReceivedListener, + getLastNotificationResponse, + registerForPushNotifications, + registerTokenWithBackend, + removeNotificationListener, +} from './src/services/pushNotifications'; +import { initializeSecureStorage } from './src/services/secureStorage'; import socketService from './src/services/socket'; -import { useAppStore } from './src/store'; +import { syncService } from './src/services/syncService'; +import { useAppStore, useNotificationStore } from './src/store'; import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; import { appLogger } from './src/utils/logger'; -import { prefetchExternalResources } from './src/utils/resourceHints'; -import { mobileAnalyticsService } from './src/services/mobileAnalytics'; -import { sentryContextService } from './src/services/sentryContext'; -import { flushLogQueue } from './src/config/logging'; -import { AnalyticsEvent, PerformanceMetric } from './src/utils/trackingEvents'; -import { batteryService } from './src/services/batteryService'; -import { startupProgressService } from './src/services/startupProgressService'; -import { StartupProgressOverlay } from './src/components/common/StartupProgressOverlay'; - -const appStartTime = Date.now(); +import { handleNotificationReceived } from './src/utils/notificationHandlers'; // Keep the splash screen visible while we fetch resources SplashScreen.preventAutoHideAsync(); -// Centralized structured logging initialized lazily in services bootstrap useEffect -// requireEnvVariables(); +// SHOW_STORYBOOK flag based on environment variable +const SHOW_STORYBOOK = process.env.EXPO_PUBLIC_STORYBOOK === 'true'; -// Preconnect to API hosts and external resources -prefetchExternalResources(); +// Centralized structured logging initialized on startup +requireEnvVariables(); + +// Initialize centralized logging on app start +initializeLogging().catch(err => { + console.error('[App] Failed to initialize logging:', err); +}); if (__DEV__) { appLogger.infoSync('Development mode: centralized logger active'); @@ -49,7 +57,7 @@ if (__DEV__) { } const App = () => { - const theme = useAppStore((state) => state.theme); + const theme = useAppStore(state => state.theme); useAdaptiveTheme(); const appStateRef = useRef(AppState.currentState); @@ -58,68 +66,26 @@ const App = () => { useEffect(() => { async function prepareApp() { try { - // Initialize progress tracking - startupProgressService.setInitializing(true); - startupProgressService.registerStep('fonts', 'Loading Fonts', 500); - startupProgressService.registerStep('cache', 'Clearing Cache', 800); - startupProgressService.registerStep('auth', 'Checking Authentication', 1000); - startupProgressService.registerStep('data', 'Loading Initial Data', 1500); - // 1. Load fonts - startupProgressService.startStep('fonts'); await Font.loadAsync({ - 'Inter-Regular': require('./assets/fonts/Inter-Regular.ttf'), - 'Inter-Bold': require('./assets/fonts/Inter-Bold.ttf'), + // You can add custom fonts here later if needed }); - startupProgressService.completeStep('fonts'); // 2. Version-based cache invalidation: clear stale caches on app/data version bump - startupProgressService.startStep('cache'); const appVersion = require('./package.json').version as string; await handleCacheVersionUpdate(appVersion); - startupProgressService.completeStep('cache'); - // 3. Check Auth State / wait for store hydration - startupProgressService.startStep('auth'); + // 2. Check Auth State / wait for store hydration // Zustand persist automatically hydrates, we can assume it's done or add a small delay // to ensure initial data fetching completes. - await new Promise(resolve => setTimeout(resolve, 300)); - startupProgressService.completeStep('auth'); - // 4. Initial data fetch (simulate or add real fetch) - startupProgressService.startStep('data'); + // 3. Initial data fetch (simulate or add real fetch) await new Promise(resolve => setTimeout(resolve, 500)); - startupProgressService.completeStep('data'); - - // 5. Warm critical caches (user profile + home feed) in parallel - await warmCriticalCaches(); } catch (e) { console.warn('Error during app initialization:', e); - // Mark the last step as failed - const inProgressStep = startupProgressService.getInProgressStep(); - if (inProgressStep) { - startupProgressService.failStep( - inProgressStep.id, - e instanceof Error ? e.message : String(e) - ); - } } finally { setAppIsReady(true); - startupProgressService.setInitializing(false); await SplashScreen.hideAsync(); - - // Track cold start metric - const coldStartDuration = Date.now() - appStartTime; - mobileAnalyticsService.trackEvent(AnalyticsEvent.PERFORMANCE_METRIC, { - metric_name: PerformanceMetric.APP_LOAD_TIME, - metric_value: coldStartDuration, - launch_type: 'cold', - }); - appLogger.infoSync(`[App] Cold start completed in ${coldStartDuration}ms`); - - // Record app launch breadcrumb so every Sentry event has launch context - sentryContextService.trackAppLifecycle('launch'); - sentryContextService.trackAction('app_cold_start', { durationMs: coldStartDuration }); } } @@ -129,22 +95,70 @@ const App = () => { const SESSION_REFRESH_WINDOW_MS = 5 * 60 * 1000; useEffect(() => { - // Initialize battery monitoring - batteryService.initialize().catch(err => { - console.error('[App] Failed to initialize battery service:', err); + // Initialize crash reporting at app startup + crashReportingService.init(); + + // Initialize secure storage (Keychain/Keystore) for encrypted token storage + initializeSecureStorage().catch(error => { + appLogger.errorSync('Failed to initialize secure storage:', error as Error); + // Continue app startup even if secure storage init fails + // (user will be prompted to re-authenticate if needed) + }); + + // Add global handler for unhandled promise rejections + const unhandledRejectionHandler = (reason: any) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + appLogger.errorSync('Unhandled Promise Rejection', error); + crashReportingService.reportError(error, 'UnhandledPromiseRejection'); + }; + + // Register unhandled rejection listener + if (global.onunhandledrejection === undefined) { + // @ts-ignore - Setting global error handler + global.onunhandledrejection = unhandledRejectionHandler; + } + + // Connect to socket when app starts + socketService.connect(); + + // Initialize push notifications: request permissions and get device token + registerForPushNotifications().then(async token => { + if (token) { + const { setPushToken, setTokenRegistered } = useNotificationStore.getState(); + setPushToken(token); + const registered = await registerTokenWithBackend(token); + setTokenRegistered(registered); + } }); - // Lazy load Sentry after core initialization - InteractionManager.runAfterInteractions(() => { - initializeLogging().catch(err => { - console.error('[App] Failed to initialize logging:', err); - }); - // Lazy connect socket.io after core initialization - socketService.connect(); + // Start request queue monitoring + requestQueue.startMonitoring(apiClient); + + // Initialize and start sync service for background sync + syncService.startAutoSync(); + + // Set up notification navigation handler + const notificationCleanup = setupNotificationNavigation(); + + // Listen for notifications received while app is foregrounded + const subscription = addNotificationReceivedListener(handleNotificationReceived); + + // Check if app was launched from a notification + getLastNotificationResponse().then(response => { + if (response) { + appLogger.infoSync('App launched from notification', { response }); + } }); + // Cleanup on unmount return () => { - batteryService.shutdown(); + socketService.disconnect(); + syncService.stopAutoSync(); + notificationCleanup(); + removeNotificationListener(subscription); + // Clean up the unhandled rejection handler + // @ts-ignore + global.onunhandledrejection = undefined; }; }, []); @@ -201,19 +215,11 @@ const App = () => { const appStateSubscription = AppState.addEventListener('change', nextAppState => { const wasInBackground = appStateRef.current.match(/inactive|background/); const isForegrounded = nextAppState === 'active'; - const isBackgrounded = appStateRef.current === 'active' && nextAppState.match(/inactive|background/); if (wasInBackground && isForegrounded) { - sentryContextService.trackAppLifecycle('foreground'); void checkSessionOnForeground(); } - if (isBackgrounded) { - sentryContextService.trackAppLifecycle('background'); - // Flush queued logs before going to background so nothing is lost - void flushLogQueue(); - } - appStateRef.current = nextAppState; }); @@ -228,18 +234,12 @@ const App = () => { return ( - - + ); }; -const AppEntry = __DEV__ && process.env.EXPO_PUBLIC_STORYBOOK === 'true' - ? // eslint-disable-next-line @typescript-eslint/no-require-imports - require('./.rnstorybook').default - : App; - -export default AppEntry; +export default SHOW_STORYBOOK ? StorybookUI : App; \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index d4f790a..e556b37 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,7 +8,6 @@ import 'react-native-reanimated'; import { MemoryProfilerOverlay } from '../components/DevTools'; import { RetryErrorBoundary } from '../components/ErrorBoundary/RetryErrorBoundary'; import '../global.css'; // NativeWind CSS - import { AnalyticsProvider, ErrorBoundary, OfflineIndicatorProvider } from '../src/components'; import { KeyboardDelegateProvider } from '../src/components/common/KeyboardDelegateProvider'; import { useAnalytics } from '../src/hooks'; @@ -157,4 +156,4 @@ const RootLayout = () => { ); }; -export default RootLayout; +export default RootLayout; \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts index f9cf9f0..d3a052a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,9 +1,23 @@ +import { ValidationResult } from '../utils/validation'; + +export interface EnvConfig { + EXPO_PUBLIC_API_BASE_URL: string; + EXPO_PUBLIC_SOCKET_URL: string; + EXPO_PUBLIC_APP_ENV?: 'development' | 'production'; + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS?: 'true' | 'false'; + EXPO_PUBLIC_STORYBOOK?: 'true' | 'false'; +} + +const REQUIRED_VARIABLES: (keyof EnvConfig)[] = [ + 'EXPO_PUBLIC_API_BASE_URL', + 'EXPO_PUBLIC_SOCKET_URL', +]; + export function validateEnvVariables(): ValidationResult { const missing: string[] = []; const errors: string[] = []; for (const variable of REQUIRED_VARIABLES) { - // Replace dynamic access with direct variable checks let value: string | undefined; if (variable === 'EXPO_PUBLIC_API_BASE_URL') { @@ -16,18 +30,28 @@ export function validateEnvVariables(): ValidationResult { missing.push(variable); errors.push( `Missing required environment variable: ${variable}. ` + - `Please set ${variable} in your .env file. ` + - `See .env.example for reference.` + `Please set ${variable} in your .env file. See .env.example for reference.` ); - } else if (variable === 'EXPO_PUBLIC_API_BASE_URL') { + continue; + } + + if (variable === 'EXPO_PUBLIC_API_BASE_URL') { try { - new URL(value); + const url = new URL(value); + if (url.protocol !== 'https:') { + errors.push( + `Invalid URL for ${variable}: ${value}. ` + + `EXPO_PUBLIC_API_BASE_URL must use https://.` + ); + } } catch { errors.push( - `Invalid URL for ${variable}: ${value}. ` + `Please provide a valid HTTP/HTTPS URL.` + `Invalid URL for ${variable}: ${value}. ` + `Please provide a valid https:// URL.` ); } - } else if (variable === 'EXPO_PUBLIC_SOCKET_URL') { + } + + if (variable === 'EXPO_PUBLIC_SOCKET_URL') { if (!value.startsWith('ws://') && !value.startsWith('wss://')) { errors.push( `Invalid WebSocket URL for ${variable}: ${value}. ` + @@ -37,18 +61,58 @@ export function validateEnvVariables(): ValidationResult { } } + if (process.env.EXPO_PUBLIC_APP_ENV) { + const envValue = process.env.EXPO_PUBLIC_APP_ENV; + if (envValue !== 'development' && envValue !== 'production') { + errors.push( + `Invalid value for EXPO_PUBLIC_APP_ENV: ${envValue}. ` + + `Allowed values are 'development' or 'production'.` + ); + } + } + + if (process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS) { + const pushValue = process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS; + if (pushValue !== 'true' && pushValue !== 'false') { + errors.push( + `Invalid value for EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: ${pushValue}. ` + + `Allowed values are 'true' or 'false'.` + ); + } + } + + if (process.env.EXPO_PUBLIC_STORYBOOK) { + const storyValue = process.env.EXPO_PUBLIC_STORYBOOK; + if (storyValue !== 'true' && storyValue !== 'false') { + errors.push( + `Invalid value for EXPO_PUBLIC_STORYBOOK: ${storyValue}. ` + + `Allowed values are 'true' or 'false'.` + ); + } + } + return { valid: missing.length === 0 && errors.length === 0, - missing, - errors, + message: errors.length > 0 ? errors.join(' ') : undefined, }; } export function requireEnvVariables(): EnvConfig { - // ... rest of function + const validation = validateEnvVariables(); + + if (!validation.valid) { + throw new Error( + `Environment Configuration Error: ${validation.message ?? 'Invalid .env values.'}` + ); + } + return { EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL!, EXPO_PUBLIC_SOCKET_URL: process.env.EXPO_PUBLIC_SOCKET_URL!, + EXPO_PUBLIC_APP_ENV: + process.env.EXPO_PUBLIC_APP_ENV === 'production' ? 'production' : 'development', + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS, + EXPO_PUBLIC_STORYBOOK: process.env.EXPO_PUBLIC_STORYBOOK, }; }