From 454a6f387f1f57b30e26b1ac7c8d2bfddeb4a838 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:56:07 +0530 Subject: [PATCH] feat: Implement privacy settings and telemetry management --- App.tsx | 38 +- index.html | 17 +- src/components/CustomAlert.tsx | 2 - src/components/metadata/RatingsSection.tsx | 48 +- src/navigation/AppNavigator.tsx | 133 ++++- src/screens/SettingsScreen.tsx | 12 + .../settings/PrivacySettingsScreen.tsx | 463 ++++++++++++++++++ src/screens/settings/index.ts | 2 + src/services/telemetryService.ts | 326 ++++++++++++ 9 files changed, 1009 insertions(+), 32 deletions(-) create mode 100644 src/screens/settings/PrivacySettingsScreen.tsx create mode 100644 src/services/telemetryService.ts diff --git a/App.tsx b/App.tsx index ffe6425fa..5bd5ff190 100644 --- a/App.tsx +++ b/App.tsx @@ -47,19 +47,45 @@ import { AccountProvider, useAccount } from './src/contexts/AccountContext'; import { ToastProvider } from './src/contexts/ToastContext'; import { mmkvStorage } from './src/services/mmkvStorage'; import { CampaignManager } from './src/components/promotions/CampaignManager'; +import { isErrorReportingEnabledSync } from './src/services/telemetryService'; +// Initialize Sentry with privacy-first defaults +// Settings are loaded from telemetryService and can be controlled by user +// Note: Full dynamic control requires app restart as Sentry initializes at startup Sentry.init({ dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', - // Adds more context data to events (IP address, cookies, user, etc.) - // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ - sendDefaultPii: true, + // Privacy-first: Disable PII by default (IP address, cookies, user data) + // Users can opt-in via Privacy Settings if they choose + sendDefaultPii: false, - // Configure Session Replay conservatively to avoid startup overhead in production - replaysSessionSampleRate: __DEV__ ? 0.1 : 0, - replaysOnErrorSampleRate: __DEV__ ? 1 : 0, + // Session Replay completely disabled by default for privacy + // This prevents screen recording without explicit user consent + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + + // Only include feedback integration (user-initiated, not automatic) integrations: [Sentry.feedbackIntegration()], + // beforeSend hook to respect user's telemetry preferences + // Uses synchronous MMKV read to check preference immediately + beforeSend: (event) => { + // Check if error reporting is disabled (synchronous check) + if (!isErrorReportingEnabledSync()) { + // Drop the event - user has opted out + return null; + } + return event; + }, + + // beforeSendTransaction hook for performance monitoring + beforeSendTransaction: (event) => { + if (!isErrorReportingEnabledSync()) { + return null; + } + return event; + }, + // uncomment the line below to enable Spotlight (https://spotlightjs.com) // spotlight: __DEV__, }); diff --git a/index.html b/index.html index 0aa061170..749e2f0ae 100644 --- a/index.html +++ b/index.html @@ -876,7 +876,7 @@ Back

Privacy

-

Last updated: January 2025

+

Last updated: February 5, 2026

No Account Sync

@@ -901,10 +901,23 @@
+
+

Telemetry & Analytics

+

Nuvio includes optional telemetry to improve stability and user experience:

+ +

You can disable all telemetry at any time in the app under Settings → Privacy & Data.

+
+

Content Disclaimer

Nuvio is a media player and aggregator. We do not host any content. All video content is diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 14fb9936f..165965091 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -68,8 +68,6 @@ export const CustomAlert = ({ const handleActionPress = useCallback((action: { label: string; onPress: () => void; style?: object }) => { try { action.onPress(); - // Don't auto-close here if the action handles it, or check if we should - // Standard behavior is to close onClose(); } catch (error) { console.warn('[CustomAlert] Error in action handler:', error); diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index 2e3ee5e29..eba6912a0 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -22,7 +22,7 @@ const BREAKPOINTS = { tv: 1440, }; -const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/6/69/IMDB_Logo_2016.svg'; +const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png'; export const RATING_PROVIDERS = { imdb: { @@ -95,21 +95,41 @@ export const RatingsSection: React.FC = ({ imdbId, type }) }, [deviceType]); const iconSize = useMemo(() => { - switch (deviceType) { - case 'tv': - return 20; - case 'largeTablet': - return 18; - case 'tablet': - return 16; - default: - return 16; + const baseSize = deviceType === 'tv' ? 20 : deviceType === 'largeTablet' ? 18 : deviceType === 'tablet' ? 16 : 16; + const numRatings = ratings ? Object.keys(ratings).length : 0; + // Reduce size if many ratings to fit in one line + if (numRatings > 4) { + return Math.max(baseSize - 2, 12); } - }, [deviceType]); + return baseSize; + }, [deviceType, ratings]); - const textSize = useMemo(() => (isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14), [isTV, isLargeTablet, isTablet]); - const itemSpacing = useMemo(() => (isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12), [isTV, isLargeTablet, isTablet]); - const iconTextGap = useMemo(() => (isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4), [isTV, isLargeTablet, isTablet]); + const textSize = useMemo(() => { + const baseSize = isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14; + const numRatings = ratings ? Object.keys(ratings).length : 0; + if (numRatings > 4) { + return Math.max(baseSize - 1, 12); + } + return baseSize; + }, [isTV, isLargeTablet, isTablet, ratings]); + + const itemSpacing = useMemo(() => { + const baseSpacing = isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12; + const numRatings = ratings ? Object.keys(ratings).length : 0; + if (numRatings > 4) { + return Math.max(baseSpacing - 2, 8); + } + return baseSpacing; + }, [isTV, isLargeTablet, isTablet, ratings]); + + const iconTextGap = useMemo(() => { + const baseGap = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4; + const numRatings = ratings ? Object.keys(ratings).length : 0; + if (numRatings > 4) { + return Math.max(baseGap - 1, 2); + } + return baseGap; + }, [isTV, isLargeTablet, isTablet, ratings]); useEffect(() => { loadProviderSettings(); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 61ac06bb3..ec219b70d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -15,8 +15,9 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility'; import { Stream } from '../types/streams'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; -import { PostHogProvider } from 'posthog-react-native'; +import { PostHogProvider, usePostHog } from 'posthog-react-native'; import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext'; +import { telemetryService, TELEMETRY_EVENTS } from '../services/telemetryService'; import { useTranslation } from 'react-i18next'; // Optional iOS Glass effect (expo-glass-effect) with safe fallback @@ -82,6 +83,7 @@ import { AboutSettingsScreen, DeveloperSettingsScreen, LegalScreen, + PrivacySettingsScreen, } from '../screens/settings'; @@ -222,6 +224,7 @@ export type RootStackParamList = { PlaybackSettings: undefined; AboutSettings: undefined; DeveloperSettings: undefined; + PrivacySettings: undefined; Legal: undefined; }; @@ -1836,6 +1839,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + @@ -1843,19 +1861,118 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta ); }; +/** + * Conditional PostHog Provider Wrapper + * + * Only initializes PostHog analytics if user has opted in via Privacy Settings. + * By default, analytics is disabled for privacy. + * Uses PostHog's optIn/optOut API for runtime control. + */ +const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [analyticsEnabled, setAnalyticsEnabled] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const posthogRef = useRef(null); + + useEffect(() => { + // Initialize telemetry service and check analytics preference + const initializeTelemetry = async () => { + try { + await telemetryService.initialize(); + setAnalyticsEnabled(telemetryService.isAnalyticsEnabled()); + } catch (error) { + console.error('Failed to initialize telemetry service:', error); + setAnalyticsEnabled(false); + } finally { + setIsInitialized(true); + } + }; + + initializeTelemetry(); + + // Listen for telemetry setting changes + const subscription = DeviceEventEmitter.addListener( + TELEMETRY_EVENTS.SETTINGS_CHANGED, + (settings) => { + setAnalyticsEnabled(settings.analyticsEnabled); + // If PostHog is available, update its opt-in/out state immediately + if (posthogRef.current) { + if (settings.analyticsEnabled) { + posthogRef.current.optIn(); + console.log('[Telemetry] PostHog opted in'); + } else { + posthogRef.current.optOut(); + console.log('[Telemetry] PostHog opted out'); + } + } + } + ); + + return () => { + subscription.remove(); + }; + }, []); + + // Wait for initialization before rendering + if (!isInitialized) { + return <>{children}; + } + + // Always wrap with PostHogProvider but control via optOut + // This allows runtime toggling without remounting the tree + return ( + + { posthogRef.current = posthog; }} + /> + {children} + + ); +}; + +/** + * Internal component to handle PostHog opt-in/opt-out + * Uses the official usePostHog hook for reliable API access + */ +const PostHogOptController: React.FC<{ + enabled: boolean; + onPostHogReady: (posthog: any) => void; +}> = ({ enabled, onPostHogReady }) => { + const posthog = usePostHog(); + + useEffect(() => { + if (posthog) { + onPostHogReady(posthog); + if (enabled) { + posthog.optIn(); + console.log('[Telemetry] PostHog opted in'); + } else { + posthog.optOut(); + console.log('[Telemetry] PostHog opted out'); + } + } + }, [enabled, posthog, onPostHogReady]); + + return null; +}; + const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => ( - + - + ); export default AppNavigator; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3cd2db1fd..bd80f254f 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -44,6 +44,7 @@ import { ContentDiscoverySettingsContent } from './settings/ContentDiscoverySett import { AppearanceSettingsContent } from './settings/AppearanceSettingsScreen'; import { IntegrationsSettingsContent } from './settings/IntegrationsSettingsScreen'; import { AboutSettingsContent, AboutFooter } from './settings/AboutSettingsScreen'; +import { PrivacySettingsContent } from './settings/PrivacySettingsScreen'; import { SettingsCard, SettingItem, ChevronRight, CustomSwitch } from './settings/SettingsComponents'; import { useBottomSheetBackHandler } from '../hooks/useBottomSheetBackHandler'; import { LOCALES } from '../constants/locales'; @@ -148,6 +149,7 @@ const SettingsScreen: React.FC = () => { { id: 'playback', title: t('settings.playback'), icon: 'play-circle' }, { id: 'backup', title: t('settings.backup_restore'), icon: 'archive' }, { id: 'updates', title: t('settings.updates'), icon: 'refresh-ccw' }, + { id: 'privacy', title: t('privacy.title', 'Privacy & Data'), icon: 'shield' }, { id: 'about', title: t('settings.about'), icon: 'info' }, { id: 'developer', title: t('settings.developer'), icon: 'code' }, { id: 'cache', title: t('settings.cache'), icon: 'database' }, @@ -429,6 +431,9 @@ const SettingsScreen: React.FC = () => { case 'about': return ; + case 'privacy': + return ; + case 'developer': return (__DEV__ || developerModeEnabled) ? ( @@ -814,6 +819,13 @@ const SettingsScreen: React.FC = () => { renderControl={() => } onPress={() => navigation.navigate('Contributors')} /> + } + onPress={() => navigation.navigate('PrivacySettings')} + /> = ({ + isTablet = false, +}) => { + const { t } = useTranslation(); + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + + // Telemetry settings state + const [settings, setSettings] = useState({ + analyticsEnabled: false, + errorReportingEnabled: true, + sessionReplayEnabled: false, + piiEnabled: false, + }); + const [isLoading, setIsLoading] = useState(true); + + const showAlert = useCallback(( + title: string, + message: string, + actions?: Array<{ label: string; onPress: () => void; style?: object }> + ) => { + const alertActions = (actions || [{ label: 'OK', onPress: () => { } }]).map(action => ({ + text: action.label, + onPress: action.onPress, + style: undefined as 'default' | 'cancel' | 'destructive' | undefined, + })); + + Alert.alert(title, message, alertActions, { cancelable: true }); + }, []); + + // Load settings on mount + useEffect(() => { + const loadSettings = async () => { + try { + await telemetryService.initialize(); + setSettings(telemetryService.getSettings()); + } catch (error) { + console.error('Failed to load telemetry settings:', error); + } finally { + setIsLoading(false); + } + }; + loadSettings(); + }, []); + + // Handle analytics toggle + const handleAnalyticsToggle = async (enabled: boolean) => { + try { + await telemetryService.setAnalyticsEnabled(enabled); + setSettings(prev => ({ ...prev, analyticsEnabled: enabled })); + + if (enabled) { + showAlert( + t('privacy.analytics_enabled_title', 'Analytics Enabled'), + t('privacy.analytics_enabled_message', 'Usage data will be collected to help improve the app. You can disable this at any time.') + ); + } + } catch (error) { + console.error('Failed to update analytics setting:', error); + } + }; + + // Handle error reporting toggle + const handleErrorReportingToggle = async (enabled: boolean) => { + if (!enabled) { + showAlert( + t('privacy.disable_error_reporting_title', 'Disable Error Reporting?'), + t('privacy.disable_error_reporting_message', 'Disabling error reporting means we won\'t be notified of crashes or issues you experience. This may affect our ability to fix bugs.'), + [ + { label: t('common.cancel', 'Cancel'), onPress: () => { } }, + { + label: t('common.disable', 'Disable'), + onPress: async () => { + await telemetryService.setErrorReportingEnabled(false); + setSettings(prev => ({ ...prev, errorReportingEnabled: false })); + } + } + ] + ); + } else { + await telemetryService.setErrorReportingEnabled(true); + setSettings(prev => ({ ...prev, errorReportingEnabled: true })); + } + }; + + // Handle session replay toggle + const handleSessionReplayToggle = async (enabled: boolean) => { + if (enabled) { + showAlert( + t('privacy.enable_session_replay_title', 'Enable Session Replay?'), + t('privacy.enable_session_replay_message', 'Session replay records your screen when errors occur to help us understand what happened. This may capture visible content on your screen.'), + [ + { label: t('common.cancel', 'Cancel'), onPress: () => { } }, + { + label: t('common.enable', 'Enable'), + onPress: async () => { + await telemetryService.setSessionReplayEnabled(true); + setSettings(prev => ({ ...prev, sessionReplayEnabled: true })); + } + } + ] + ); + } else { + await telemetryService.setSessionReplayEnabled(false); + setSettings(prev => ({ ...prev, sessionReplayEnabled: false })); + } + }; + + // Handle PII toggle + const handlePiiToggle = async (enabled: boolean) => { + if (enabled) { + showAlert( + t('privacy.enable_pii_title', 'Enable PII Collection?'), + t('privacy.enable_pii_message', 'This allows collection of personally identifiable information like IP address and device details. This data helps diagnose issues but increases privacy exposure.'), + [ + { label: t('common.cancel', 'Cancel'), onPress: () => { } }, + { + label: t('common.enable', 'Enable'), + onPress: async () => { + await telemetryService.setPiiEnabled(true); + setSettings(prev => ({ ...prev, piiEnabled: true })); + } + } + ] + ); + } else { + await telemetryService.setPiiEnabled(false); + setSettings(prev => ({ ...prev, piiEnabled: false })); + } + }; + + // Disable all telemetry + const handleDisableAll = () => { + showAlert( + t('privacy.disable_all_title', 'Disable All Telemetry?'), + t('privacy.disable_all_message', 'This will disable all analytics, error reporting, and session replay. We won\'t receive any data about app usage or crashes.'), + [ + { label: t('common.cancel', 'Cancel'), onPress: () => { } }, + { + label: t('privacy.disable_all_button', 'Disable All'), + onPress: async () => { + await telemetryService.disableAllTelemetry(); + setSettings({ + analyticsEnabled: false, + errorReportingEnabled: false, + sessionReplayEnabled: false, + piiEnabled: false, + }); + // Delay showing the next alert to avoid Reanimated conflicts + setTimeout(() => { + showAlert( + t('privacy.all_disabled_title', 'All Telemetry Disabled'), + t('privacy.all_disabled_message', 'All data collection has been disabled. Changes take effect on next app restart.') + ); + }, 300); + } + } + ] + ); + }; + + // Reset to recommended defaults + const handleResetToRecommended = async () => { + await telemetryService.enableRecommendedTelemetry(); + setSettings({ + analyticsEnabled: false, + errorReportingEnabled: true, + sessionReplayEnabled: false, + piiEnabled: false, + }); + // No chained alert here, this is direct so it's fine + showAlert( + t('privacy.reset_title', 'Reset to Recommended'), + t('privacy.reset_message', 'Privacy settings have been reset to recommended defaults (error reporting enabled, analytics disabled).') + ); + }; + + if (isLoading) { + return ( + + + {t('common.loading', 'Loading...')} + + + ); + } + + return ( + <> + {/* Info Card */} + + + {t('privacy.info_title', 'Your Privacy Matters')} + + + {t('privacy.info_description', 'Control what data is collected and shared. All settings take effect on the next app restart. By default, only anonymous error reporting is enabled to help us fix crashes.')} + + + + {/* Analytics Section */} + + ( + + )} + isLast + isTablet={isTablet} + /> + + + {/* Error Reporting Section */} + + ( + + )} + isTablet={isTablet} + /> + ( + + )} + isTablet={isTablet} + /> + ( + + )} + isLast + isTablet={isTablet} + /> + + + {/* Quick Actions */} + + } + isTablet={isTablet} + /> + } + isLast + isTablet={isTablet} + /> + + + {/* Learn More */} + + Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')} + renderControl={() => } + isTablet={isTablet} + /> + + + {/* Data Summary */} + + + {t('privacy.current_settings', 'Current Settings Summary')} + + + + + {t('privacy.summary_analytics', 'Analytics')}: {settings.analyticsEnabled ? t('common.on', 'On') : t('common.off', 'Off')} + + + + + + {t('privacy.summary_errors', 'Error Reports')}: {settings.errorReportingEnabled ? t('common.on', 'On') : t('common.off', 'Off')} + + + + + + {t('privacy.summary_replay', 'Session Replay')}: {settings.sessionReplayEnabled ? t('common.on', 'On') : t('common.off', 'Off')} + + + + + + {t('privacy.summary_pii', 'Device Info')}: {settings.piiEnabled ? t('common.on', 'On') : t('common.off', 'Off')} + + + + {t('privacy.restart_note_detailed', '* Analytics and error reporting changes take effect immediately. Session replay and PII settings require app restart.')} + + + + + ); +}; + +/** + * PrivacySettingsScreen - Wrapper for mobile navigation + */ +const PrivacySettingsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const screenIsTablet = width >= 768; + + return ( + + + navigation.goBack()} + /> + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 16, + }, + loadingContainer: { + padding: 40, + alignItems: 'center', + }, + loadingText: { + fontSize: 16, + }, + infoCard: { + marginHorizontal: 16, + marginBottom: 20, + padding: 16, + borderRadius: 16, + borderWidth: 1, + }, + infoTitle: { + fontSize: 17, + fontWeight: '600', + marginBottom: 8, + }, + infoText: { + fontSize: 14, + lineHeight: 20, + }, + summaryCard: { + marginHorizontal: 16, + marginTop: 8, + marginBottom: 20, + padding: 16, + borderRadius: 16, + borderWidth: 1, + }, + summaryTitle: { + fontSize: 15, + fontWeight: '600', + marginBottom: 12, + }, + summaryRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 10, + }, + summaryText: { + fontSize: 14, + }, + restartNote: { + fontSize: 12, + fontStyle: 'italic', + marginTop: 8, + }, +}); + +export default PrivacySettingsScreen; diff --git a/src/screens/settings/index.ts b/src/screens/settings/index.ts index effbc3e60..caadbeb0a 100644 --- a/src/screens/settings/index.ts +++ b/src/screens/settings/index.ts @@ -6,6 +6,7 @@ export { default as PlaybackSettingsScreen } from './PlaybackSettingsScreen'; export { default as AboutSettingsScreen } from './AboutSettingsScreen'; export { default as DeveloperSettingsScreen } from './DeveloperSettingsScreen'; export { default as LegalScreen } from './LegalScreen'; +export { default as PrivacySettingsScreen } from './PrivacySettingsScreen'; // Reusable content component exports (for inline use on tablets) export { ContentDiscoverySettingsContent } from './ContentDiscoverySettingsScreen'; @@ -13,6 +14,7 @@ export { AppearanceSettingsContent } from './AppearanceSettingsScreen'; export { IntegrationsSettingsContent } from './IntegrationsSettingsScreen'; export { PlaybackSettingsContent } from './PlaybackSettingsScreen'; export { AboutSettingsContent, AboutFooter } from './AboutSettingsScreen'; +export { PrivacySettingsContent } from './PrivacySettingsScreen'; // Shared UI component exports export { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents'; diff --git a/src/services/telemetryService.ts b/src/services/telemetryService.ts new file mode 100644 index 000000000..d29844bf0 --- /dev/null +++ b/src/services/telemetryService.ts @@ -0,0 +1,326 @@ +/** + * Telemetry Service + * + * Manages user preferences for telemetry, analytics, and error reporting. + * Provides a central opt-out mechanism for privacy-conscious users. + * + * Data Collection Overview: + * - Analytics (PostHog): Page views, interactions, session data, device metadata + * - Error Reporting (Sentry): Crash reports, errors, breadcrumbs, device info + * - Session Replay: Screen recordings on errors (disabled by default) + * + * Privacy-First Defaults: + * - Analytics: Disabled by default + * - Error Reporting: Enabled (no PII) by default for stability + * - Session Replay: Disabled by default + * - PII Collection: Disabled by default + */ + +import { mmkvStorage } from './mmkvStorage'; +import { DeviceEventEmitter } from 'react-native'; +import { createMMKV } from 'react-native-mmkv'; + +// Direct MMKV access for synchronous reads (needed for Sentry beforeSend) +const directMMKV = createMMKV(); + +// Storage keys for telemetry preferences +const TELEMETRY_KEYS = { + ANALYTICS_ENABLED: 'telemetry_analytics_enabled', + ERROR_REPORTING_ENABLED: 'telemetry_error_reporting_enabled', + SESSION_REPLAY_ENABLED: 'telemetry_session_replay_enabled', + PII_ENABLED: 'telemetry_pii_enabled', + TELEMETRY_INITIALIZED: 'telemetry_initialized', +} as const; + +// Event names for telemetry changes +export const TELEMETRY_EVENTS = { + SETTINGS_CHANGED: 'telemetry_settings_changed', +} as const; + +export interface TelemetrySettings { + analyticsEnabled: boolean; + errorReportingEnabled: boolean; + sessionReplayEnabled: boolean; + piiEnabled: boolean; +} + +// Default settings - Privacy-first approach +const DEFAULT_SETTINGS: TelemetrySettings = { + analyticsEnabled: false, // Disabled by default - user must opt-in + errorReportingEnabled: true, // Enabled for app stability, but without PII + sessionReplayEnabled: false, // Disabled by default - high privacy impact + piiEnabled: false, // Never send PII by default +}; + +/** + * Synchronously read a setting directly from MMKV + * Used by Sentry's beforeSend hook which runs synchronously + */ +function readSettingSync(key: string): string | undefined { + try { + return directMMKV.getString(key); + } catch { + return undefined; + } +} + +/** + * Check if error reporting is enabled (synchronous version for Sentry) + * This is called from Sentry's beforeSend hook + */ +export function isErrorReportingEnabledSync(): boolean { + const value = readSettingSync(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED); + // Default to true if not set (privacy-safe default for stability) + return value !== 'false'; +} + +class TelemetryService { + private static instance: TelemetryService; + private settings: TelemetrySettings = { ...DEFAULT_SETTINGS }; + private initialized = false; + + private constructor() { + // Synchronously load settings on construction for immediate availability + this.loadSettingsSync(); + } + + public static getInstance(): TelemetryService { + if (!TelemetryService.instance) { + TelemetryService.instance = new TelemetryService(); + } + return TelemetryService.instance; + } + + /** + * Synchronously load settings from MMKV (for immediate availability) + */ + private loadSettingsSync(): void { + try { + const analytics = readSettingSync(TELEMETRY_KEYS.ANALYTICS_ENABLED); + const errorReporting = readSettingSync(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED); + const sessionReplay = readSettingSync(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED); + const pii = readSettingSync(TELEMETRY_KEYS.PII_ENABLED); + + this.settings = { + analyticsEnabled: analytics === 'true', + errorReportingEnabled: errorReporting !== 'false', // Default true + sessionReplayEnabled: sessionReplay === 'true', + piiEnabled: pii === 'true', + }; + } catch (error) { + console.error('[TelemetryService] Error loading settings sync:', error); + this.settings = { ...DEFAULT_SETTINGS }; + } + } + + /** + * Initialize telemetry service and load saved preferences + */ + async initialize(): Promise { + if (this.initialized) { + return this.settings; + } + + try { + // Check if this is first run (no telemetry preferences saved yet) + const telemetryInitialized = await mmkvStorage.getItem(TELEMETRY_KEYS.TELEMETRY_INITIALIZED); + + if (telemetryInitialized !== 'true') { + // First run - use defaults and mark as initialized + await this.saveSettings(DEFAULT_SETTINGS); + await mmkvStorage.setItem(TELEMETRY_KEYS.TELEMETRY_INITIALIZED, 'true'); + this.settings = { ...DEFAULT_SETTINGS }; + } else { + // Load saved preferences + const [analytics, errorReporting, sessionReplay, pii] = await Promise.all([ + mmkvStorage.getItem(TELEMETRY_KEYS.ANALYTICS_ENABLED), + mmkvStorage.getItem(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED), + mmkvStorage.getItem(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED), + mmkvStorage.getItem(TELEMETRY_KEYS.PII_ENABLED), + ]); + + this.settings = { + analyticsEnabled: analytics === 'true', + errorReportingEnabled: errorReporting !== 'false', // Default true if not explicitly disabled + sessionReplayEnabled: sessionReplay === 'true', + piiEnabled: pii === 'true', + }; + } + + this.initialized = true; + console.log('[TelemetryService] Initialized with settings:', this.settings); + } catch (error) { + console.error('[TelemetryService] Error initializing:', error); + // Use defaults on error + this.settings = { ...DEFAULT_SETTINGS }; + this.initialized = true; + } + + return this.settings; + } + + /** + * Get current telemetry settings + */ + getSettings(): TelemetrySettings { + return { ...this.settings }; + } + + /** + * Check if analytics is enabled + */ + isAnalyticsEnabled(): boolean { + return this.settings.analyticsEnabled; + } + + /** + * Check if error reporting is enabled + */ + isErrorReportingEnabled(): boolean { + return this.settings.errorReportingEnabled; + } + + /** + * Check if session replay is enabled + */ + isSessionReplayEnabled(): boolean { + return this.settings.sessionReplayEnabled; + } + + /** + * Check if PII collection is enabled + */ + isPiiEnabled(): boolean { + return this.settings.piiEnabled; + } + + /** + * Update analytics setting + */ + async setAnalyticsEnabled(enabled: boolean): Promise { + this.settings.analyticsEnabled = enabled; + await mmkvStorage.setItem(TELEMETRY_KEYS.ANALYTICS_ENABLED, enabled.toString()); + this.emitSettingsChanged(); + console.log('[TelemetryService] Analytics enabled:', enabled); + } + + /** + * Update error reporting setting + */ + async setErrorReportingEnabled(enabled: boolean): Promise { + this.settings.errorReportingEnabled = enabled; + await mmkvStorage.setItem(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED, enabled.toString()); + this.emitSettingsChanged(); + console.log('[TelemetryService] Error reporting enabled:', enabled); + } + + /** + * Update session replay setting + */ + async setSessionReplayEnabled(enabled: boolean): Promise { + this.settings.sessionReplayEnabled = enabled; + await mmkvStorage.setItem(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED, enabled.toString()); + this.emitSettingsChanged(); + console.log('[TelemetryService] Session replay enabled:', enabled); + } + + /** + * Update PII collection setting + */ + async setPiiEnabled(enabled: boolean): Promise { + this.settings.piiEnabled = enabled; + await mmkvStorage.setItem(TELEMETRY_KEYS.PII_ENABLED, enabled.toString()); + this.emitSettingsChanged(); + console.log('[TelemetryService] PII enabled:', enabled); + } + + /** + * Disable all telemetry (global opt-out) + */ + async disableAllTelemetry(): Promise { + this.settings = { + analyticsEnabled: false, + errorReportingEnabled: false, + sessionReplayEnabled: false, + piiEnabled: false, + }; + await this.saveSettings(this.settings); + this.emitSettingsChanged(); + console.log('[TelemetryService] All telemetry disabled'); + } + + /** + * Enable recommended telemetry (error reporting only, no PII) + */ + async enableRecommendedTelemetry(): Promise { + this.settings = { + analyticsEnabled: false, + errorReportingEnabled: true, + sessionReplayEnabled: false, + piiEnabled: false, + }; + await this.saveSettings(this.settings); + this.emitSettingsChanged(); + console.log('[TelemetryService] Recommended telemetry enabled'); + } + + /** + * Reset to default settings + */ + async resetToDefaults(): Promise { + this.settings = { ...DEFAULT_SETTINGS }; + await this.saveSettings(this.settings); + this.emitSettingsChanged(); + console.log('[TelemetryService] Reset to defaults'); + } + + /** + * Save all settings to storage + */ + private async saveSettings(settings: TelemetrySettings): Promise { + await Promise.all([ + mmkvStorage.setItem(TELEMETRY_KEYS.ANALYTICS_ENABLED, settings.analyticsEnabled.toString()), + mmkvStorage.setItem(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED, settings.errorReportingEnabled.toString()), + mmkvStorage.setItem(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED, settings.sessionReplayEnabled.toString()), + mmkvStorage.setItem(TELEMETRY_KEYS.PII_ENABLED, settings.piiEnabled.toString()), + ]); + } + + /** + * Emit event when settings change + */ + private emitSettingsChanged(): void { + DeviceEventEmitter.emit(TELEMETRY_EVENTS.SETTINGS_CHANGED, this.settings); + } + + /** + * Get Sentry configuration based on current settings + */ + getSentryConfig(): { + enabled: boolean; + sendDefaultPii: boolean; + replaysSessionSampleRate: number; + replaysOnErrorSampleRate: number; + } { + return { + enabled: this.settings.errorReportingEnabled, + sendDefaultPii: this.settings.piiEnabled, + replaysSessionSampleRate: this.settings.sessionReplayEnabled ? 0.1 : 0, + replaysOnErrorSampleRate: this.settings.sessionReplayEnabled ? 1.0 : 0, + }; + } + + /** + * Get PostHog configuration based on current settings + */ + getPostHogConfig(): { + enabled: boolean; + } { + return { + enabled: this.settings.analyticsEnabled, + }; + } +} + +export const telemetryService = TelemetryService.getInstance(); +export default telemetryService;