Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ EXPO_PUBLIC_APP_ENV=production

# Feature Flags
EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS=true
EXPO_PUBLIC_STORYBOOK=false
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged
190 changes: 95 additions & 95 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -49,7 +57,7 @@ if (__DEV__) {
}

const App = () => {
const theme = useAppStore((state) => state.theme);
const theme = useAppStore(state => state.theme);
useAdaptiveTheme();

const appStateRef = useRef<AppStateStatus>(AppState.currentState);
Expand All @@ -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 });
}
}

Expand All @@ -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;
};
}, []);

Expand Down Expand Up @@ -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;
});

Expand All @@ -228,18 +234,12 @@ const App = () => {

return (
<ErrorBoundary>
<StartupProgressOverlay />
<AuthProvider>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<AppNavigator />
<ExpoRoot />
</AuthProvider>
</ErrorBoundary>
);
};

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;
3 changes: 1 addition & 2 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -157,4 +156,4 @@ const RootLayout = () => {
);
};

export default RootLayout;
export default RootLayout;
Loading