feat: Implement privacy settings and telemetry management

This commit is contained in:
tapframe 2026-02-05 10:56:07 +05:30
parent c43e6d879f
commit 454a6f387f
9 changed files with 1009 additions and 32 deletions

38
App.tsx
View file

@ -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__,
});

View file

@ -876,7 +876,7 @@
Back
</button>
<h1>Privacy</h1>
<p class="last-updated">Last updated: January 2025</p>
<p class="last-updated">Last updated: February 5, 2026</p>
<div class="privacy-section">
<h2>No Account Sync</h2>
@ -901,10 +901,23 @@
<ul>
<li>TMDB for metadata like posters and cast info</li>
<li>Trakt.tv (optional) for watch history sync</li>
<li>Sentry for anonymous crash reporting</li>
<li>Sentry for crash reporting (privacy-first defaults, no PII by default)</li>
<li>PostHog for optional analytics (disabled by default)</li>
</ul>
</div>
<div class="privacy-section">
<h2>Telemetry & Analytics</h2>
<p>Nuvio includes optional telemetry to improve stability and user experience:</p>
<ul>
<li><strong>Sentry</strong> captures crash reports and errors. Personal data (IP, device identifiers)
is disabled by default and can be enabled only if you opt in.</li>
<li><strong>PostHog</strong> analytics are disabled by default and require explicit opt-in.</li>
<li><strong>Session Replay</strong> is disabled by default and can be enabled only if you opt in.</li>
</ul>
<p>You can disable all telemetry at any time in the app under <strong>Settings → Privacy & Data</strong>.</p>
</div>
<div class="privacy-section">
<h2>Content Disclaimer</h2>
<p>Nuvio is a media player and aggregator. We do not host any content. All video content is

View file

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

View file

@ -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<RatingsSectionProps> = ({ 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();

View file

@ -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
},
}}
/>
<Stack.Screen
name="PrivacySettings"
component={PrivacySettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'default' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>
@ -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<any>(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 (
<PostHogProvider
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
options={{
host: "https://us.i.posthog.com",
autocapture: analyticsEnabled,
// Start opted out if analytics is disabled
defaultOptIn: analyticsEnabled,
}}
autocapture={analyticsEnabled}
>
<PostHogOptController
enabled={analyticsEnabled}
onPostHogReady={(posthog) => { posthogRef.current = posthog; }}
/>
{children}
</PostHogProvider>
);
};
/**
* 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 }) => (
<PostHogProvider
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
options={{
host: "https://us.i.posthog.com",
}}
>
<ConditionalPostHogProvider>
<ScrollToTopProvider>
<LoadingProvider>
<InnerNavigator initialRouteName={initialRouteName} />
</LoadingProvider>
</ScrollToTopProvider>
</PostHogProvider>
</ConditionalPostHogProvider>
);
export default AppNavigator;

View file

@ -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 <AboutSettingsContent isTablet={isTablet} displayDownloads={displayDownloads} />;
case 'privacy':
return <PrivacySettingsContent isTablet={isTablet} />;
case 'developer':
return (__DEV__ || developerModeEnabled) ? (
<SettingsCard title={t('settings.sections.testing')} isTablet={isTablet}>
@ -814,6 +819,13 @@ const SettingsScreen: React.FC = () => {
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('Contributors')}
/>
<SettingItem
title={t('privacy.title', 'Privacy & Data')}
description={t('privacy.settings_desc', 'Control telemetry and data collection')}
icon="shield"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('PrivacySettings')}
/>
<SettingItem
title={t('settings.about_nuvio')}
description={getDisplayedAppVersion()}

View file

@ -0,0 +1,463 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
StatusBar,
Dimensions,
Linking,
Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../contexts/ThemeContext';
import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader';
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
import { useTranslation } from 'react-i18next';
import { telemetryService, TelemetrySettings } from '../../services/telemetryService';
const { width } = Dimensions.get('window');
interface PrivacySettingsContentProps {
isTablet?: boolean;
}
/**
* Privacy Settings Content Component
*
* Provides user control over telemetry, analytics, and error reporting.
*
* Data Collection Summary:
* - Analytics (PostHog): Usage patterns, screen views, interactions
* - Error Reporting (Sentry): Crash reports and errors for app stability
* - Session Replay: Screen recordings when errors occur
* - PII: Personal identifiable information (IP, device info, etc.)
*/
export const PrivacySettingsContent: React.FC<PrivacySettingsContentProps> = ({
isTablet = false,
}) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
// Telemetry settings state
const [settings, setSettings] = useState<TelemetrySettings>({
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 (
<View style={styles.loadingContainer}>
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
{t('common.loading', 'Loading...')}
</Text>
</View>
);
}
return (
<>
{/* Info Card */}
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
{t('privacy.info_title', 'Your Privacy Matters')}
</Text>
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
{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.')}
</Text>
</View>
{/* Analytics Section */}
<SettingsCard title={t('privacy.section_analytics', 'ANALYTICS')} isTablet={isTablet}>
<SettingItem
title={t('privacy.analytics_title', 'Usage Analytics')}
description={t('privacy.analytics_description', 'Collect anonymous usage patterns and screen views')}
icon="bar-chart-2"
renderControl={() => (
<CustomSwitch
value={settings.analyticsEnabled}
onValueChange={handleAnalyticsToggle}
/>
)}
isLast
isTablet={isTablet}
/>
</SettingsCard>
{/* Error Reporting Section */}
<SettingsCard title={t('privacy.section_error_reporting', 'ERROR REPORTING')} isTablet={isTablet}>
<SettingItem
title={t('privacy.error_reporting_title', 'Crash Reports')}
description={t('privacy.error_reporting_description', 'Send anonymous crash reports to improve stability')}
icon="alert-circle"
renderControl={() => (
<CustomSwitch
value={settings.errorReportingEnabled}
onValueChange={handleErrorReportingToggle}
/>
)}
isTablet={isTablet}
/>
<SettingItem
title={t('privacy.session_replay_title', 'Session Replay')}
description={t('privacy.session_replay_description', 'Record screen when errors occur')}
icon="video"
renderControl={() => (
<CustomSwitch
value={settings.sessionReplayEnabled}
onValueChange={handleSessionReplayToggle}
/>
)}
isTablet={isTablet}
/>
<SettingItem
title={t('privacy.pii_title', 'Include Device Info')}
description={t('privacy.pii_description', 'Send IP address and device details with reports')}
icon="user"
renderControl={() => (
<CustomSwitch
value={settings.piiEnabled}
onValueChange={handlePiiToggle}
/>
)}
isLast
isTablet={isTablet}
/>
</SettingsCard>
{/* Quick Actions */}
<SettingsCard title={t('privacy.section_quick_actions', 'QUICK ACTIONS')} isTablet={isTablet}>
<SettingItem
title={t('privacy.disable_all', 'Disable All Telemetry')}
description={t('privacy.disable_all_desc', 'Turn off all data collection')}
icon="shield-off"
onPress={handleDisableAll}
renderControl={() => <ChevronRight />}
isTablet={isTablet}
/>
<SettingItem
title={t('privacy.reset_recommended', 'Reset to Recommended')}
description={t('privacy.reset_recommended_desc', 'Privacy-first defaults with error reporting')}
icon="refresh-cw"
onPress={handleResetToRecommended}
renderControl={() => <ChevronRight />}
isLast
isTablet={isTablet}
/>
</SettingsCard>
{/* Learn More */}
<SettingsCard title={t('privacy.section_learn_more', 'LEARN MORE')} isTablet={isTablet}>
<SettingItem
title={t('privacy.privacy_policy', 'Privacy Policy')}
icon="file-text"
onPress={() => Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')}
renderControl={() => <ChevronRight />}
isTablet={isTablet}
/>
</SettingsCard>
{/* Data Summary */}
<View style={[styles.summaryCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.summaryTitle, { color: currentTheme.colors.highEmphasis }]}>
{t('privacy.current_settings', 'Current Settings Summary')}
</Text>
<View style={styles.summaryRow}>
<View style={[styles.statusDot, { backgroundColor: settings.analyticsEnabled ? '#4CAF50' : '#9E9E9E' }]} />
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
{t('privacy.summary_analytics', 'Analytics')}: {settings.analyticsEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
</Text>
</View>
<View style={styles.summaryRow}>
<View style={[styles.statusDot, { backgroundColor: settings.errorReportingEnabled ? '#4CAF50' : '#9E9E9E' }]} />
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
{t('privacy.summary_errors', 'Error Reports')}: {settings.errorReportingEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
</Text>
</View>
<View style={styles.summaryRow}>
<View style={[styles.statusDot, { backgroundColor: settings.sessionReplayEnabled ? '#FF9800' : '#9E9E9E' }]} />
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
{t('privacy.summary_replay', 'Session Replay')}: {settings.sessionReplayEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
</Text>
</View>
<View style={styles.summaryRow}>
<View style={[styles.statusDot, { backgroundColor: settings.piiEnabled ? '#FF9800' : '#9E9E9E' }]} />
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
{t('privacy.summary_pii', 'Device Info')}: {settings.piiEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
</Text>
</View>
<Text style={[styles.restartNote, { color: currentTheme.colors.mediumEmphasis }]}>
{t('privacy.restart_note_detailed', '* Analytics and error reporting changes take effect immediately. Session replay and PII settings require app restart.')}
</Text>
</View>
</>
);
};
/**
* PrivacySettingsScreen - Wrapper for mobile navigation
*/
const PrivacySettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const screenIsTablet = width >= 768;
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader
title={t('privacy.title', 'Privacy & Data')}
showBackButton
onBackPress={() => navigation.goBack()}
/>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 40 }]}
>
<PrivacySettingsContent isTablet={screenIsTablet} />
</ScrollView>
</View>
);
};
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;

View file

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

View file

@ -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<TelemetrySettings> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;