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
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;