From 1070e595442b34805018f64751e18d4df2f52c9f Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:09:35 +0530 Subject: [PATCH 1/2] fix: gate automatic telemetry behind ANALYTICS_ENABLED build flag F-Droid review flagged that every launch silently posts a persistent install UUID, platform, timestamp, and usage stats to the Railway backend with no Tracking AntiFeature disclosure. Add a compile-time ANALYTICS_ENABLED flag (default true) so the F-Droid build recipe can pass --dart-define=ANALYTICS_ENABLED=false to disable it, while GitHub-release builds keep the existing behavior. backupData is left ungated since it's a user-initiated action, not passive telemetry. Co-Authored-By: Claude Sonnet 5 --- workout-logger/lib/services/api_service.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 4b93765..ffdcaa2 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -15,6 +15,16 @@ class ApiService { defaultValue: 'https://workout-logger-production-1e93.up.railway.app', ); + /// Gates the *automatic* telemetry (heartbeat/app-open/usage-report) sent + /// on every launch. Off by default in the F-Droid build + /// (`--dart-define=ANALYTICS_ENABLED=false`, see fdroiddata build recipe) + /// since that build has no Tracking AntiFeature disclosure. Does not gate + /// [backupData], which is a user-initiated action, not passive telemetry. + static const bool _analyticsEnabled = bool.fromEnvironment( + 'ANALYTICS_ENABLED', + defaultValue: true, + ); + static final ApiService _instance = ApiService._internal(); factory ApiService() => _instance; @@ -57,6 +67,7 @@ class ApiService { // ───────── ingest helpers ───────── Future sendHeartbeat() async { + if (!_analyticsEnabled) return; try { final id = await userAppId; final body = { @@ -85,6 +96,7 @@ class ApiService { String event, { Map? metadata, }) async { + if (!_analyticsEnabled) return; try { final id = await userAppId; final body = { @@ -112,6 +124,7 @@ class ApiService { } Future reportUsage(Map stats) async { + if (!_analyticsEnabled) return; try { final id = await userAppId; final payload = { From 842255fb3ef5016533f068d965ccc0187c94b832 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:25:22 +0530 Subject: [PATCH 2/2] fix: gate telemetry on runtime F-Droid detection, not a compile flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gated automatic telemetry behind a --dart-define=ANALYTICS_ENABLED build flag, but that would have broken F-Droid's byte-for-byte reproducible-build check: F-Droid's rebuild- from-source has to match the GitHub release APK referenced by `Binaries:` in fdroiddata, and a compile-time constant that differs between the two builds means the compiled output never matches. Replace it with a runtime check in SettingsProvider: - isFdroidInstall detects the F-Droid client via PackageInfo.installerStore == 'org.fdroid.fdroid' (same binary either way — nothing compiled in differs between build channels). - analyticsEnabled is now a user-facing Settings toggle (SettingsProvider.setAnalyticsEnabled), defaulting to on. - telemetryAllowed = analyticsEnabled && !isFdroidInstall gates the three automatic calls in main.dart. F-Droid installs are always telemetry-free regardless of the toggle; other installs can opt out. Also adds the "Privacy" section to the profile screen, and removes the "Cloud Backup" action tile and "Cloud Sync" MongoDB placeholder card (unimplemented, and the same Railway backend as the telemetry this fix is about). Co-Authored-By: Claude Sonnet 5 --- workout-logger/lib/main.dart | 16 ++- .../lib/screens/profile_screen.dart | 27 +--- .../lib/screens/widgets/profile_sections.dart | 135 +++++++----------- workout-logger/lib/services/api_service.dart | 13 -- .../lib/services/settings_provider.dart | 34 +++++ 5 files changed, 93 insertions(+), 132 deletions(-) diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 8d512d6..f1cefb3 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -200,12 +200,16 @@ class _AppInitializerState extends State { // so the opt-in flag is loaded; never blocks or fails app init. readiness.refresh(); - // Fire-and-forget analytics in background. - api.sendHeartbeat(); - api.trackEvent('app_open'); - provider.getQuickStats().then((stats) => api.reportUsage(stats)).catchError( - (Object e) => debugPrint('Failed to report usage: $e'), - ); + // Fire-and-forget analytics in background — never fires for F-Droid + // installs, and honors the user's Settings toggle otherwise. Must + // run after settings.init() so both are loaded. + if (settings.telemetryAllowed) { + api.sendHeartbeat(); + api.trackEvent('app_open'); + provider.getQuickStats().then((stats) => api.reportUsage(stats)).catchError( + (Object e) => debugPrint('Failed to report usage: $e'), + ); + } if (!mounted) return; setState(() { diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index ecfbfaa..e7432fe 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -14,7 +14,6 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; -import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; import '../services/managers/readiness_manager.dart'; import '../theme/app_theme.dart'; @@ -31,7 +30,6 @@ class _ProfileScreenState extends State with WidgetsBindingObserver { bool _isExporting = false; bool _isImporting = false; - bool _isBackingUp = false; bool _isRequestingHcPermission = false; bool _isRequestingReadinessPermission = false; String _appVersion = ''; @@ -295,27 +293,6 @@ class _ProfileScreenState extends State } } - Future _performCloudBackup() async { - setState(() => _isBackingUp = true); - final provider = context.read(); - final api = context.read(); - try { - final jsonString = await provider.exportAllData(); - final data = jsonDecode(jsonString) as Map; - await api.trackEvent('backup_triggered').catchError((_) => null); - final success = await api.backupData(data); - if (!mounted) return; - _showSnack( - success ? 'Cloud backup successful!' : 'Backup failed. Please try again.', - success ? AppColors.success : AppColors.error, - ); - } catch (_) { - if (mounted) _showSnack('Something went wrong.', AppColors.error); - } finally { - if (mounted) setState(() => _isBackingUp = false); - } - } - void _showSnack(String message, Color color) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -379,15 +356,13 @@ class _ProfileScreenState extends State DataManagementSection( isExporting: _isExporting, isImporting: _isImporting, - isBackingUp: _isBackingUp, onExport: _isExporting ? null : _exportToFile, onImport: _isImporting ? null : _importFromFile, - onCloudBackup: _isBackingUp ? null : _performCloudBackup, ), const SizedBox(height: AppSpacing.md), const AiSettingsSection(), const SizedBox(height: AppSpacing.md), - const CloudSyncSection(), + PrivacySection(settings: settings), const SizedBox(height: AppSpacing.md), AboutSection(appVersion: _appVersion), const SizedBox(height: AppSpacing.xxl), diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index d6bbee3..313c176 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -356,18 +356,14 @@ class DataManagementSection extends StatelessWidget { super.key, required this.isExporting, required this.isImporting, - required this.isBackingUp, required this.onExport, required this.onImport, - required this.onCloudBackup, }); final bool isExporting; final bool isImporting; - final bool isBackingUp; final VoidCallback? onExport; final VoidCallback? onImport; - final VoidCallback? onCloudBackup; @override Widget build(BuildContext context) { @@ -375,7 +371,7 @@ class DataManagementSection extends StatelessWidget { icon: Icons.storage_rounded, iconColor: AppColors.secondary, title: 'Data Management', - subtitle: 'Export, import, or backup your workout data', + subtitle: 'Export or import your workout data', child: Column( children: [ _ActionTile( @@ -395,77 +391,67 @@ class DataManagementSection extends StatelessWidget { loading: isImporting, onTap: onImport, ), - const _SectionDivider(), - _ActionTile( - icon: Icons.cloud_upload_outlined, - iconColor: AppColors.primary, - title: 'Cloud Backup', - subtitle: 'Sync to RepForge cloud (requires account)', - loading: isBackingUp, - onTap: onCloudBackup, - ), ], ), ); } } -// ── Cloud Sync section (placeholder) ───────────────────────────────────────── -class CloudSyncSection extends StatelessWidget { - const CloudSyncSection({super.key}); +// ── Privacy section ──────────────────────────────────────────────────────────── +class PrivacySection extends StatelessWidget { + const PrivacySection({super.key, required this.settings}); + + final SettingsProvider settings; + + static const _color = AppColors.textSoft; @override Widget build(BuildContext context) { + final isFdroid = settings.isFdroidInstall; + final enabled = settings.telemetryAllowed; return _ProfileSection( - icon: Icons.sync_rounded, - iconColor: AppColors.warning, - title: 'Cloud Sync', - subtitle: 'Sync your data across devices', - trailing: const _ComingSoonBadge(), + icon: Icons.privacy_tip_outlined, + iconColor: _color, + title: 'Privacy', + subtitle: 'Control anonymous usage data', child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionLabel('MONGODB CONNECTION STRING'), - const SizedBox(height: AppSpacing.sm), - Container( - decoration: BoxDecoration( - color: AppColors.glass, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), - ), - child: TextField( - enabled: false, - style: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textFaint, - fontSize: 12, - ), - decoration: InputDecoration( - hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', - hintStyle: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textFaint, - fontSize: 12, - ), - prefixIcon: const Icon( - Icons.link_rounded, - color: AppColors.textFaint, - size: 16, - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 4, + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share anonymous usage data', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + isFdroid + ? 'Always off for F-Droid installs' + : 'Install ID, platform, and workout counts — no personal data', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], ), ), - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Cloud sync with custom MongoDB will be available in a future update.', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textFaint, - fontSize: 11, - fontStyle: FontStyle.italic, - ), + Switch( + value: enabled, + onChanged: isFdroid + ? null + : (v) => settings.setAnalyticsEnabled(v), + activeThumbColor: _color, + activeTrackColor: _color.withValues(alpha: 0.35), + ), + ], ), ], ), @@ -1154,28 +1140,3 @@ class _DebugLogSheet extends StatelessWidget { ); } } - -class _ComingSoonBadge extends StatelessWidget { - const _ComingSoonBadge(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)), - ), - child: Text( - 'Soon', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.warning, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - ); - } -} diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index ffdcaa2..4b93765 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -15,16 +15,6 @@ class ApiService { defaultValue: 'https://workout-logger-production-1e93.up.railway.app', ); - /// Gates the *automatic* telemetry (heartbeat/app-open/usage-report) sent - /// on every launch. Off by default in the F-Droid build - /// (`--dart-define=ANALYTICS_ENABLED=false`, see fdroiddata build recipe) - /// since that build has no Tracking AntiFeature disclosure. Does not gate - /// [backupData], which is a user-initiated action, not passive telemetry. - static const bool _analyticsEnabled = bool.fromEnvironment( - 'ANALYTICS_ENABLED', - defaultValue: true, - ); - static final ApiService _instance = ApiService._internal(); factory ApiService() => _instance; @@ -67,7 +57,6 @@ class ApiService { // ───────── ingest helpers ───────── Future sendHeartbeat() async { - if (!_analyticsEnabled) return; try { final id = await userAppId; final body = { @@ -96,7 +85,6 @@ class ApiService { String event, { Map? metadata, }) async { - if (!_analyticsEnabled) return; try { final id = await userAppId; final body = { @@ -124,7 +112,6 @@ class ApiService { } Future reportUsage(Map stats) async { - if (!_analyticsEnabled) return; try { final id = await userAppId; final payload = { diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 164df92..fbd9b8d 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -20,6 +20,8 @@ class SettingsProvider extends ChangeNotifier { String _weeklyInsights = ''; DateTime? _weeklyInsightsDate; bool _showAdvancedMetrics = false; + bool _analyticsEnabled = true; + bool _isFdroidInstall = false; WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; @@ -34,6 +36,22 @@ class SettingsProvider extends ChangeNotifier { DateTime? get weeklyInsightsDate => _weeklyInsightsDate; bool get showAdvancedMetrics => _showAdvancedMetrics; + /// Whether this install came from the F-Droid client (detected at runtime + /// via the Android installer package name — never baked in at compile + /// time, since a compile-time difference between the F-Droid build and + /// the GitHub release binary would break F-Droid's byte-for-byte + /// reproducible-build verification against `Binaries:` in fdroiddata). + bool get isFdroidInstall => _isFdroidInstall; + + /// User's analytics preference, as stored. F-Droid installs are always + /// telemetry-free regardless of this value — see [telemetryAllowed]. + bool get analyticsEnabled => _analyticsEnabled; + + /// Whether automatic telemetry (heartbeat/app-open/usage-report) may + /// fire. False for F-Droid installs unconditionally; otherwise follows + /// the user's setting. + bool get telemetryAllowed => _analyticsEnabled && !_isFdroidInstall; + SettingsProvider(this._storage); Future init() async { @@ -60,6 +78,16 @@ class SettingsProvider extends ChangeNotifier { _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; final advMetrics = await _storage.getSetting('showAdvancedMetrics'); _showAdvancedMetrics = advMetrics == 'true'; + + final analytics = await _storage.getSetting('analyticsEnabled'); + _analyticsEnabled = analytics != 'false'; + + try { + final info = await PackageInfo.fromPlatform(); + _isFdroidInstall = info.installerStore == 'org.fdroid.fdroid'; + } catch (_) { + _isFdroidInstall = false; + } } Future setUserName(String name) async { @@ -130,6 +158,12 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setAnalyticsEnabled(bool value) async { + _analyticsEnabled = value; + await _storage.saveSetting('analyticsEnabled', value.toString()); + notifyListeners(); + } + Future saveWeeklyInsights(String insights) async { _weeklyInsights = insights; _weeklyInsightsDate = DateTime.now();