diff --git a/app/(tabs)/reference.tsx b/app/(tabs)/reference.tsx index 740020b3..0e6afaa7 100644 --- a/app/(tabs)/reference.tsx +++ b/app/(tabs)/reference.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, TextInput, Modal } from 'react-native'; import { Star, MessageSquare, X } from 'lucide-react-native'; import { collection, addDoc } from 'firebase/firestore'; -import { db } from '../../lib/firebase'; +import { getDbInstance } from '../../lib/firebase'; import { isMobileWeb } from '../../lib/utils'; interface CommonDose { @@ -131,6 +131,7 @@ export default function ReferenceScreen() { setIsSubmitting(true); try { // Store suggestion in Firebase + const db = await getDbInstance(); await addDoc(collection(db, 'compound-suggestions'), { compoundName: compoundName.trim(), dosageRange: dosageRange.trim(), diff --git a/app/_layout.tsx b/app/_layout.tsx index 8e60189e..d20556e8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,7 +3,6 @@ import { Slot, SplashScreen } from 'expo-router'; import { useEffect } from 'react'; import { AuthProvider } from '../contexts/AuthContext'; import { UserProfileProvider } from '../contexts/UserProfileContext'; -import { getAnalyticsInstance } from '../lib/firebase'; import "../global.css"; export default function RootLayout() { @@ -12,14 +11,6 @@ export default function RootLayout() { useEffect(() => { console.log('[RootLayout] Root layout effect running - hiding splash screen'); SplashScreen.hideAsync(); - - // Initialize Firebase Analytics lazily - const analytics = getAnalyticsInstance(); - if (analytics) { - console.log('[Analytics] Firebase Analytics initialized'); - } else { - console.log('[Analytics] Firebase Analytics not available (likely not web platform)'); - } }, []); console.log('[RootLayout] Rendering providers and slot'); diff --git a/app/success.tsx b/app/success.tsx index 8e9df555..90ae8e57 100644 --- a/app/success.tsx +++ b/app/success.tsx @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; import { doc, setDoc } from 'firebase/firestore'; import { getAuth } from 'firebase/auth'; -import { db } from '../lib/firebase'; +import { getDbInstance } from '../lib/firebase'; import Constants from 'expo-constants'; import { logAnalyticsEvent, setAnalyticsUserProperties, ANALYTICS_EVENTS, USER_PROPERTIES } from '../lib/analytics'; @@ -69,6 +69,7 @@ export default function SuccessScreen() { const auth = getAuth(); const user = auth.currentUser; if (user) { + const db = await getDbInstance(); const userRef = doc(db, 'users', user.uid); await setDoc(userRef, { plan: 'plus', limit: 150, scansUsed: 0 }, { merge: true }); diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index 01ee206d..4392d757 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useEffect, useState, useRef } from 'r import { onAuthStateChanged, signInAnonymously, signOut, User, Auth } from 'firebase/auth'; import { ActivityIndicator } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { auth } from '@/lib/firebase'; // Initialized auth instance +import { getAuthInstance } from '@/lib/firebase'; // Updated to use async function import { logAnalyticsEvent, setAnalyticsUserProperties, ANALYTICS_EVENTS, USER_PROPERTIES } from '@/lib/analytics'; interface AuthContextType { @@ -18,10 +18,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [isSigningOut, setIsSigningOut] = useState(false); + const [authInstance, setAuthInstance] = useState(null); const isSigningOutRef = useRef(false); const isSigningInAnonymouslyRef = useRef(false); + // Initialize auth instance asynchronously + useEffect(() => { + const initAuth = async () => { + try { + const auth = await getAuthInstance(); + setAuthInstance(auth); + } catch (error) { + console.error('[AuthContext] Failed to initialize auth:', error); + } + }; + initAuth(); + }, []); + const logout = async () => { + if (!authInstance) { + console.warn('[AuthContext] Auth instance not ready for logout'); + return; + } + console.log('[AuthContext] ========== LOGOUT INITIATED =========='); console.log('[AuthContext] Current user before logout:', user ? { uid: user.uid, @@ -38,7 +57,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { console.log('[AuthContext] isSigningOut state and ref updated to true'); console.log('[AuthContext] Calling Firebase signOut...'); - await signOut(auth); + await signOut(authInstance); console.log('[AuthContext] Firebase signOut completed successfully'); console.log('[AuthContext] Clearing user state...'); @@ -98,10 +117,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, [isSigningOut]); useEffect(() => { + if (!authInstance) { + // Wait for auth instance to be initialized + return; + } + let timeoutId: NodeJS.Timeout | null = null; - // Subscribe to auth state changes on the single `auth` instance - const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => { + // Subscribe to auth state changes on the auth instance + const unsubscribe = onAuthStateChanged(authInstance, (firebaseUser) => { console.log('[AuthContext] ========== AUTH STATE CHANGED =========='); console.log('[AuthContext] New user state:', firebaseUser ? { uid: firebaseUser.uid, @@ -149,7 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!isSigningOutRef.current && !isSigningInAnonymouslyRef.current) { console.log('[AuthContext] Not signing out and not already signing in anonymously - signing in anonymously immediately'); isSigningInAnonymouslyRef.current = true; - signInAnonymously(auth) + signInAnonymously(authInstance) .then(() => { console.log('[AuthContext] ✅ Signed in anonymously successfully'); isSigningInAnonymouslyRef.current = false; @@ -174,7 +198,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isSigningOutRef.current = false; if (!isSigningInAnonymouslyRef.current) { isSigningInAnonymouslyRef.current = true; - signInAnonymously(auth) + signInAnonymously(authInstance) .then(() => { console.log('[AuthContext] ✅ Signed in anonymously after logout'); isSigningInAnonymouslyRef.current = false; @@ -204,14 +228,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { clearTimeout(timeoutId); } }; - }, []); // Remove isSigningOut dependency to prevent auth listener recreation + }, [authInstance]); // Add authInstance dependency - if (loading) { + if (loading || !authInstance) { return ; } return ( - + {children} ); diff --git a/contexts/UserProfileContext.tsx b/contexts/UserProfileContext.tsx index 551493fc..471c58fe 100644 --- a/contexts/UserProfileContext.tsx +++ b/contexts/UserProfileContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { doc, setDoc, getDoc } from 'firebase/firestore'; -import { db } from '@/lib/firebase'; +import { getDbInstance } from '@/lib/firebase'; import { useAuth } from '@/contexts/AuthContext'; import { UserProfile, WarningLevel, getUserWarningLevel, getDisclaimerText } from '@/types/userProfile'; import { logAnalyticsEvent, ANALYTICS_EVENTS, setPersonalizationUserProperties } from '@/lib/analytics'; @@ -38,6 +38,7 @@ export function UserProfileProvider({ children }: { children: React.ReactNode }) // User has transitioned from anonymous to authenticated // Check if profile needs to be backed up to Firebase try { + const db = await getDbInstance(); const docRef = doc(db, 'userProfiles', user.uid); const docSnap = await getDoc(docRef); @@ -119,6 +120,7 @@ export function UserProfileProvider({ children }: { children: React.ReactNode }) // For authenticated users, try Firebase first, then local storage fallback console.log('[UserProfile] Authenticated user detected - trying Firebase first'); try { + const db = await getDbInstance(); const docRef = doc(db, 'userProfiles', user.uid); const docSnap = await getDoc(docRef); @@ -150,6 +152,7 @@ export function UserProfileProvider({ children }: { children: React.ReactNode }) // try to backup this profile to Firebase (this handles cases where // user has local profile but it's not synced to Firebase) try { + const db = await getDbInstance(); const docRef = doc(db, 'userProfiles', user.uid); const profileToBackup = { ...profileData, @@ -241,6 +244,7 @@ export function UserProfileProvider({ children }: { children: React.ReactNode }) // Save to Firebase if user is available if (user?.uid) { try { + const db = await getDbInstance(); const docRef = doc(db, 'userProfiles', user.uid); await setDoc(docRef, newProfile); console.log('User profile saved to Firebase'); @@ -294,6 +298,7 @@ export function UserProfileProvider({ children }: { children: React.ReactNode }) // Clear from Firebase if user is available if (user?.uid) { try { + const db = await getDbInstance(); const docRef = doc(db, 'userProfiles', user.uid); await setDoc(docRef, {}, { merge: false }); // Effectively deletes the document console.log('User profile cleared from Firebase'); diff --git a/lib/analytics.ts b/lib/analytics.ts index c326a513..68eb49e9 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -1,5 +1,58 @@ -import { logEvent, setUserProperties } from 'firebase/analytics'; -import { getAnalyticsInstance } from './firebase'; +/** + * A completely safe, in-memory queue for analytics events. + * THIS FILE MUST NOT IMPORT ANYTHING FROM FIREBASE. + */ + +type QueuedOperation = { + type: 'logEvent'; + payload: { eventName: string; eventParams?: { [key: string]: any } }; +} | { + type: 'setUserProperties'; + payload: { properties: { [key: string]: any } }; +}; + +// This queue is globally accessible and safe. +export const analyticsQueue: QueuedOperation[] = []; +export let isAnalyticsInitialized = false; + +// The functions the app will call. They just add to the queue. +export const logAnalyticsEvent = (eventName: string, eventParams?: { [key: string]: any }) => { + if (isAnalyticsInitialized) { + console.warn(`[Analytics Queue] Analytics already initialized, but old log function called for ${eventName}. This is a bug.`); + return; + } + console.log(`[Analytics Queue] Queuing event: ${eventName}`); + analyticsQueue.push({ type: 'logEvent', payload: { eventName, eventParams } }); +}; + +export const setAnalyticsUserProperties = (properties: { [key: string]: any }) => { + if (isAnalyticsInitialized) { + console.warn(`[Analytics Queue] Analytics already initialized, but old setUserProperties called. This is a bug.`); + return; + } + console.log(`[Analytics Queue] Queuing user properties:`, properties); + analyticsQueue.push({ type: 'setUserProperties', payload: { properties } }); +}; + +// Helper function to set personalization user properties from profile +export const setPersonalizationUserProperties = (profile: any) => { + // Determine user segment based on profile + let userSegment = 'general_user'; + if (profile.isLicensedProfessional) { + userSegment = 'healthcare_professional'; + } else if (profile.isCosmeticUse) { + userSegment = 'cosmetic_user'; + } else if (profile.isPersonalUse) { + userSegment = 'personal_medical_user'; + } + + setAnalyticsUserProperties({ + [USER_PROPERTIES.IS_LICENSED_PROFESSIONAL]: profile.isLicensedProfessional, + [USER_PROPERTIES.IS_PERSONAL_USE]: profile.isPersonalUse, + [USER_PROPERTIES.IS_COSMETIC_USE]: profile.isCosmeticUse, + [USER_PROPERTIES.USER_SEGMENT]: userSegment, + }); +}; // Custom event names as defined in the issue export const ANALYTICS_EVENTS = { @@ -58,52 +111,11 @@ export const USER_PROPERTIES = { USER_SEGMENT: 'user_segment', // Derived from profile settings } as const; -// Helper function to safely log analytics events -export const logAnalyticsEvent = (eventName: string, parameters?: Record) => { - const analytics = getAnalyticsInstance(); - if (analytics) { - try { - logEvent(analytics, eventName, parameters); - console.log(`[Analytics] Event logged: ${eventName}`, parameters); - } catch (error) { - console.error(`[Analytics] Failed to log event ${eventName}:`, error); - } - } else { - console.log(`[Analytics] Analytics not available, would log: ${eventName}`, parameters); - } -}; - -// Helper function to safely set user properties -export const setAnalyticsUserProperties = (properties: Record) => { - const analytics = getAnalyticsInstance(); - if (analytics) { - try { - setUserProperties(analytics, properties); - console.log(`[Analytics] User properties set:`, properties); - } catch (error) { - console.error(`[Analytics] Failed to set user properties:`, error); - } - } else { - console.log(`[Analytics] Analytics not available, would set properties:`, properties); - } -}; - -// Helper function to set personalization user properties from profile -export const setPersonalizationUserProperties = (profile: any) => { - // Determine user segment based on profile - let userSegment = 'general_user'; - if (profile.isLicensedProfessional) { - userSegment = 'healthcare_professional'; - } else if (profile.isCosmeticUse) { - userSegment = 'cosmetic_user'; - } else if (profile.isPersonalUse) { - userSegment = 'personal_medical_user'; - } - - setAnalyticsUserProperties({ - [USER_PROPERTIES.IS_LICENSED_PROFESSIONAL]: profile.isLicensedProfessional, - [USER_PROPERTIES.IS_PERSONAL_USE]: profile.isPersonalUse, - [USER_PROPERTIES.IS_COSMETIC_USE]: profile.isCosmeticUse, - [USER_PROPERTIES.USER_SEGMENT]: userSegment, - }); +/** + * Signal that the Analytics provider has taken over. + * Called by the AnalyticsProvider component. + */ +export const markAnalyticsInitialized = () => { + isAnalyticsInitialized = true; + console.log(`[Analytics Queue] Analytics provider has taken over. Queue contained ${analyticsQueue.length} operations.`); }; \ No newline at end of file diff --git a/lib/dose-logging-integration.test.ts b/lib/dose-logging-integration.test.ts index 2979e97d..4e8fe0f4 100644 --- a/lib/dose-logging-integration.test.ts +++ b/lib/dose-logging-integration.test.ts @@ -102,7 +102,6 @@ describe('Automatic Dose Logging Integration', () => { expect(formatDrawToText(legacyLog)).toBeNull(); }); - }); it('should properly format timestamps', () => { const now = new Date(); diff --git a/lib/firebase.ts b/lib/firebase.ts index 868fd008..7ffaf0e5 100644 --- a/lib/firebase.ts +++ b/lib/firebase.ts @@ -1,108 +1,125 @@ -import { initializeApp, FirebaseApp } from "firebase/app"; -import { getAuth, Auth } from "firebase/auth"; -import { getAnalytics, Analytics } from "firebase/analytics"; -import { getFirestore, Firestore } from "firebase/firestore"; -import Constants from "expo-constants"; - -// Firebase configuration from app.config.js -const firebaseConfig = Constants.expoConfig?.extra?.firebase || { - apiKey: "AIzaSyCOcwQe3AOdanV43iSwYlNxhzSKSRIOq34", - authDomain: "safedose-e320d.firebaseapp.com", - projectId: "safedose-e320d", - storageBucket: "safedose-e320d.firebasestorage.app", - messagingSenderId: "704055775889", - appId: "1:704055775889:web:6ff0d3de5fea40b5b56530", - measurementId: "G-WRY88Q57KK", +import { initializeApp, getApps, getApp, FirebaseApp } from 'firebase/app'; +import { getAuth, initializeAuth, getReactNativePersistence, Auth } from 'firebase/auth'; +import { getFirestore, Firestore } from 'firebase/firestore'; +import Constants from 'expo-constants'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// --- Singleton Instances --- +let appInstance: FirebaseApp | null = null; +let authInstance: Auth | null = null; +let dbInstance: Firestore | null = null; +let appInitializationPromise: Promise | null = null; + +/** + * [CRITICAL] Returns the Firebase config *without* the measurementId. + * This is used for the initial, safe app initialization to prevent the Analytics bug. + */ +export const getInitialFirebaseConfig = () => { + const config = Constants.expoConfig?.extra?.firebase; + if (!config) return null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { measurementId, ...initialConfig } = config; + return initialConfig; }; -// Lazy initialization - nothing is initialized at module load time -let app: FirebaseApp | undefined = undefined; -let authInstance: Auth | undefined = undefined; -let dbInstance: Firestore | undefined = undefined; -let analyticsInstance: Analytics | undefined = undefined; - -const getFirebaseApp = (): FirebaseApp => { - if (!app) { - try { - console.log('[Firebase] Initializing Firebase app...'); - app = initializeApp(firebaseConfig); - console.log('[Firebase] Firebase app initialized successfully'); - } catch (error) { - console.error('[Firebase] Failed to initialize Firebase app:', error); - console.error('[Firebase] Error details:', { - message: error?.message, - stack: error?.stack, - name: error?.name - }); - throw error; - } - } - return app; +/** + * Returns the full Firebase config, including the measurementId. + * To be used only after the app has stabilized. + */ +export const getFullFirebaseConfig = () => { + return Constants.expoConfig?.extra?.firebase; }; -export const getAuthInstance = (): Auth => { - if (!authInstance) { - try { - console.log('[Firebase] Initializing Firebase Auth...'); - authInstance = getAuth(getFirebaseApp()); - console.log('[Firebase] Firebase Auth initialized successfully'); - } catch (error) { - console.error('[Firebase] Failed to initialize Firebase Auth:', error); - throw error; - } +/** + * Initializes and returns the Firebase app using the SAFE config (no measurementId). + * This prevents the Analytics module from being processed during app initialization. + */ +export const getFirebaseApp = (): Promise => { + if (appInitializationPromise) { + return appInitializationPromise; } - return authInstance; -}; -export const getDbInstance = (): Firestore => { - if (!dbInstance) { - try { - console.log('[Firebase] Initializing Firestore...'); - dbInstance = getFirestore(getFirebaseApp()); - console.log('[Firebase] Firestore initialized successfully'); - } catch (error) { - console.error('[Firebase] Failed to initialize Firestore:', error); - throw error; + appInitializationPromise = (async () => { + if (appInstance) { + return appInstance; } - } - return dbInstance; + + const config = getInitialFirebaseConfig(); + if (!config) { + throw new Error('Firebase configuration is missing'); + } + + console.log('[Firebase] Initializing with SAFE config (no measurementId)'); + + // Check if an app already exists + const existingApps = getApps(); + if (existingApps.length > 0) { + appInstance = existingApps[0]; + console.log('[Firebase] Using existing Firebase app'); + } else { + appInstance = initializeApp(config); + console.log('[Firebase] New Firebase app initialized'); + } + + return appInstance; + })(); + + return appInitializationPromise; }; -export const getAnalyticsInstance = (): Analytics | undefined => { - if (typeof window === "undefined") { - console.log('[Firebase] Analytics not available - not in browser environment'); - return undefined; +/** + * Gets the Firebase Auth instance + */ +export const getAuthInstance = async (): Promise => { + if (authInstance) { + return authInstance; } + + const app = await getFirebaseApp(); - if (!analyticsInstance) { - try { - console.log('[Firebase] Initializing Firebase Analytics...'); - analyticsInstance = getAnalytics(getFirebaseApp()); - console.log('[Firebase] Firebase Analytics initialized successfully'); - } catch (error) { - console.error('[Firebase] Analytics initialization failed:', error); - console.error('[Firebase] Analytics error details:', { - message: error?.message, - stack: error?.stack, - name: error?.name + try { + // For React Native, we need to use initializeAuth with persistence + if (typeof window === 'undefined' || !window.location) { + authInstance = initializeAuth(app, { + persistence: getReactNativePersistence(AsyncStorage) }); - return undefined; + } else { + authInstance = getAuth(app); + } + } catch (error: any) { + // If auth is already initialized, get the existing instance + if (error.code === 'auth/already-initialized') { + authInstance = getAuth(app); + } else { + throw error; } } - - return analyticsInstance; + + return authInstance; +}; + +/** + * Gets the Firestore instance + */ +export const getDbInstance = async (): Promise => { + if (dbInstance) { + return dbInstance; + } + + const app = await getFirebaseApp(); + dbInstance = getFirestore(app); + return dbInstance; }; -// For backward compatibility, provide auth and db as getters -// These will initialize on first access rather than at module load time +// For backward compatibility, provide auth and db as async getters export const auth = new Proxy({} as Auth, { get(target, prop) { - return getAuthInstance()[prop as keyof Auth]; + throw new Error('Auth must be accessed asynchronously. Use getAuthInstance() instead.'); } }); export const db = new Proxy({} as Firestore, { get(target, prop) { - return getDbInstance()[prop as keyof Firestore]; + throw new Error('Firestore must be accessed asynchronously. Use getDbInstance() instead.'); } }); \ No newline at end of file diff --git a/lib/hooks/useOnboardingIntentStorage.ts b/lib/hooks/useOnboardingIntentStorage.ts index 397abb7e..d6207719 100644 --- a/lib/hooks/useOnboardingIntentStorage.ts +++ b/lib/hooks/useOnboardingIntentStorage.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { collection, addDoc } from 'firebase/firestore'; -import { db } from '@/lib/firebase'; +import { getDbInstance } from '@/lib/firebase'; import { UserProfileAnswers } from '@/types/userProfile'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -60,6 +60,7 @@ export function useOnboardingIntentStorage() { console.log('[OnboardingIntent] Submitting intent data:', intentData); // Save to Firestore (no authentication required) + const db = await getDbInstance(); const onboardingIntentCollection = collection(db, 'onboarding_intent_submissions'); const docRef = await addDoc(onboardingIntentCollection, intentData);