From f7be206c557237c45ee4a45443597baff9dc167b Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:47:30 -0600 Subject: [PATCH] fix(mobile): redact sensitive diagnostic logs (#2199) * fix(mobile): redact sensitive diagnostic logs * fix(mobile): tighten diagnostic log redaction * fix(mobile): harden sanitized diagnostics * fix(mobile): clarify offline fallback diagnostics * fix(mobile): refine diagnostic log redaction * fix(mobile): tighten diagnostic log redaction * fix(mobile): harden auth diagnostic failures --- mobile/lib/providers/auth_provider.dart | 54 ++-- mobile/lib/providers/chat_provider.dart | 32 +- .../lib/providers/transactions_provider.dart | 27 +- mobile/lib/screens/backend_config_screen.dart | 54 ++-- mobile/lib/screens/calendar_screen.dart | 90 +++--- mobile/lib/screens/settings_screen.dart | 73 +++-- mobile/lib/services/auth_service.dart | 289 ++++++++++-------- mobile/lib/services/database_helper.dart | 6 +- mobile/lib/services/log_service.dart | 82 ++++- .../lib/services/offline_storage_service.dart | 78 +++-- mobile/lib/services/sync_service.dart | 141 ++++++--- mobile/test/services/auth_service_test.dart | 51 ++++ mobile/test/services/log_service_test.dart | 166 ++++++++++ 13 files changed, 809 insertions(+), 334 deletions(-) create mode 100644 mobile/test/services/auth_service_test.dart create mode 100644 mobile/test/services/log_service_test.dart diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 884ef3d53..c9be3741f 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -58,6 +58,13 @@ class AuthProvider with ChangeNotifier { _loadStoredAuth(); } + void _logAuthException(String operation, Object error) { + LogService.instance.error( + 'AuthProvider', + '$operation failed with ${error.runtimeType}', + ); + } + Future _loadStoredAuth() async { _isLoading = true; _isInitializing = true; @@ -127,7 +134,10 @@ class AuthProvider with ChangeNotifier { otpCode: otpCode, ); - LogService.instance.debug('AuthProvider', 'Login result: $result'); + LogService.instance.debug( + 'AuthProvider', + 'Login result received: success=${result['success'] == true}, mfa_required=${result['mfa_required'] == true}', + ); if (result['success'] == true) { _tokens = result['tokens'] as AuthTokens?; @@ -141,7 +151,8 @@ class AuthProvider with ChangeNotifier { if (result['mfa_required'] == true) { _mfaRequired = true; _showMfaInput = true; // Show MFA input field - LogService.instance.debug('AuthProvider', 'MFA required! Setting _showMfaInput to true'); + LogService.instance.debug( + 'AuthProvider', 'MFA required! Setting _showMfaInput to true'); // If user already submitted an OTP code, this is likely an invalid OTP error // Show the error message so user knows the code was wrong @@ -165,7 +176,9 @@ class AuthProvider with ChangeNotifier { return false; } } catch (e) { - _errorMessage = 'Connection error: ${e.toString()}'; + _logAuthException('Login', e); + _errorMessage = + 'Unable to connect. Please check your network and try again.'; _isLoading = false; notifyListeners(); return false; @@ -182,7 +195,10 @@ class AuthProvider with ChangeNotifier { try { final result = await _authService.loginWithApiKey(apiKey: apiKey); - LogService.instance.debug('AuthProvider', 'API key login result: $result'); + LogService.instance.debug( + 'AuthProvider', + 'API key login result received: success=${result['success'] == true}', + ); if (result['success'] == true) { _apiKey = apiKey; @@ -197,9 +213,10 @@ class AuthProvider with ChangeNotifier { notifyListeners(); return false; } - } catch (e, stackTrace) { - LogService.instance.error('AuthProvider', 'API key login error: $e\n$stackTrace'); - _errorMessage = 'Unable to connect. Please check your network and try again.'; + } catch (e) { + _logAuthException('API key login', e); + _errorMessage = + 'Unable to connect. Please check your network and try again.'; _isLoading = false; notifyListeners(); return false; @@ -241,7 +258,9 @@ class AuthProvider with ChangeNotifier { return false; } } catch (e) { - _errorMessage = 'Connection error: ${e.toString()}'; + _logAuthException('Signup', e); + _errorMessage = + 'Unable to connect. Please check your network and try again.'; _isLoading = false; notifyListeners(); return false; @@ -260,12 +279,13 @@ class AuthProvider with ChangeNotifier { deviceInfo: deviceInfo, ); - final launched = await launchUrl(Uri.parse(ssoUrl), mode: LaunchMode.externalApplication); + final launched = await launchUrl(Uri.parse(ssoUrl), + mode: LaunchMode.externalApplication); if (!launched) { _errorMessage = 'Unable to open browser for sign-in.'; } - } catch (e, stackTrace) { - LogService.instance.error('AuthProvider', 'SSO launch error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('SSO launch', e); _errorMessage = 'Unable to start sign-in. Please try again.'; } finally { _isLoading = false; @@ -306,8 +326,8 @@ class AuthProvider with ChangeNotifier { notifyListeners(); return false; } - } catch (e, stackTrace) { - LogService.instance.error('AuthProvider', 'SSO callback error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('SSO callback', e); _errorMessage = 'Sign-in failed. Please try again.'; _isLoading = false; notifyListeners(); @@ -349,8 +369,8 @@ class AuthProvider with ChangeNotifier { notifyListeners(); return false; } - } catch (e, stackTrace) { - LogService.instance.error('AuthProvider', 'SSO link error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('SSO link', e); _errorMessage = 'Failed to link account. Please try again.'; _isLoading = false; notifyListeners(); @@ -392,8 +412,8 @@ class AuthProvider with ChangeNotifier { notifyListeners(); return false; } - } catch (e, stackTrace) { - LogService.instance.error('AuthProvider', 'SSO create account error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('SSO create account', e); _errorMessage = 'Failed to create account. Please try again.'; _isLoading = false; notifyListeners(); diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index 2eb6ed212..6fc02e715 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -3,9 +3,11 @@ import 'package:flutter/foundation.dart'; import '../models/chat.dart'; import '../models/message.dart'; import '../services/chat_service.dart'; +import '../services/log_service.dart'; class ChatProvider with ChangeNotifier { final ChatService _chatService = ChatService(); + final LogService _log = LogService.instance; List _chats = []; Chat? _currentChat; @@ -60,7 +62,7 @@ class ChatProvider with ChangeNotifier { _errorMessage = result['error'] ?? 'Failed to fetch chats'; } } catch (e) { - debugPrint('fetchChats error: $e'); + _log.warning('ChatProvider', 'fetchChats failed: ${e.runtimeType}'); _errorMessage = 'Something went wrong. Please try again.'; } finally { _isLoading = false; @@ -94,7 +96,7 @@ class ChatProvider with ChangeNotifier { _errorMessage = result['error'] ?? 'Failed to fetch chat'; } } catch (e) { - debugPrint('fetchChat error: $e'); + _log.warning('ChatProvider', 'fetchChat failed: ${e.runtimeType}'); _errorMessage = 'Something went wrong. Please try again.'; } finally { _isLoading = false; @@ -153,7 +155,7 @@ class ChatProvider with ChangeNotifier { return null; } } catch (e) { - debugPrint('createChat error: $e'); + _log.warning('ChatProvider', 'createChat failed: ${e.runtimeType}'); _errorMessage = 'Something went wrong. Please try again.'; _isLoading = false; notifyListeners(); @@ -164,9 +166,8 @@ class ChatProvider with ChangeNotifier { void _rollbackOptimisticMessage(String optimisticId, String chatId) { if (_currentChat != null && _currentChat!.id == chatId) { _currentChat = _currentChat!.copyWith( - messages: _currentChat!.messages - .where((m) => m.id != optimisticId) - .toList(), + messages: + _currentChat!.messages.where((m) => m.id != optimisticId).toList(), ); } _isWaitingForResponse = false; @@ -236,7 +237,7 @@ class ChatProvider with ChangeNotifier { } catch (e) { // Roll back the optimistic message on error. _rollbackOptimisticMessage(optimisticId, chatId); - debugPrint('sendMessage error: $e'); + _log.warning('ChatProvider', 'sendMessage failed: ${e.runtimeType}'); _errorMessage = 'Something went wrong. Please try again.'; return false; } finally { @@ -282,7 +283,7 @@ class ChatProvider with ChangeNotifier { notifyListeners(); } } catch (e) { - debugPrint('updateChatTitle error: $e'); + _log.warning('ChatProvider', 'updateChatTitle failed: ${e.runtimeType}'); _errorMessage = 'Something went wrong. Please try again.'; notifyListeners(); } @@ -314,7 +315,7 @@ class ChatProvider with ChangeNotifier { return false; } } catch (e) { - debugPrint('deleteChat error: $e'); + _log.warning('ChatProvider', 'deleteChat failed: ${e.runtimeType}'); _errorMessage = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -334,7 +335,8 @@ class ChatProvider with ChangeNotifier { final deletedCount = (result['deletedCount'] as int?) ?? 0; if (result['success'] == true || deletedCount > 0) { - final failedIds = ((result['failedIds'] as List?) ?? []).cast().toSet(); + final failedIds = + ((result['failedIds'] as List?) ?? []).cast().toSet(); final deleted = chatIds.toSet().difference(failedIds); _chats.removeWhere((c) => deleted.contains(c.id)); @@ -350,7 +352,10 @@ class ChatProvider with ChangeNotifier { notifyListeners(); return false; } catch (e) { - debugPrint('deleteMultipleChats error: $e'); + _log.warning( + 'ChatProvider', + 'deleteMultipleChats failed: ${e.runtimeType}', + ); _errorMessage = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -477,14 +482,15 @@ class ChatProvider with ChangeNotifier { } catch (e) { // Network error — allow polling to continue; timeout check below will // stop it if the deadline has passed. - debugPrint('Polling error: ${e.toString()}'); + _log.warning('ChatProvider', 'Polling failed: ${e.runtimeType}'); } // Evaluate timeout only after the attempt, and only when no progress was made. if (_pollingStartTime != null && DateTime.now().difference(_pollingStartTime!) >= _pollingTimeout) { _stopPolling(); - _errorMessage = 'The assistant took too long to respond. Please try again.'; + _errorMessage = + 'The assistant took too long to respond. Please try again.'; notifyListeners(); } } diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart index 07e04c68b..8d63a2ed6 100644 --- a/mobile/lib/providers/transactions_provider.dart +++ b/mobile/lib/providers/transactions_provider.dart @@ -102,8 +102,10 @@ class TransactionsProvider with ChangeNotifier { accountId: accountId, ); - _log.debug('TransactionsProvider', - 'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)'); + _log.debug( + 'TransactionsProvider', + 'Loaded ${localTransactions.length} transactions from local storage${accountId != null ? " with account filter" : ""}', + ); _transactions = localTransactions; notifyListeners(); @@ -114,8 +116,10 @@ class TransactionsProvider with ChangeNotifier { 'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}'); if (isOnline && (forceSync || localTransactions.isEmpty)) { - _log.debug('TransactionsProvider', - 'Syncing from server for accountId: $accountId'); + _log.debug( + 'TransactionsProvider', + 'Syncing transactions from server${accountId != null ? " with account filter" : ""}', + ); final result = await _syncService.syncFromServer( accessToken: accessToken, accountId: accountId, @@ -168,8 +172,10 @@ class TransactionsProvider with ChangeNotifier { try { final isOnline = _connectivityService?.isOnline ?? false; - _log.info('TransactionsProvider', - 'Creating transaction: $name, amount: $amount, online: $isOnline'); + _log.info( + 'TransactionsProvider', + 'Creating transaction locally, online: $isOnline', + ); // ALWAYS save locally first (offline-first strategy) final localTransaction = await _offlineStorage.saveTransaction( @@ -189,8 +195,7 @@ class TransactionsProvider with ChangeNotifier { syncStatus: SyncStatus.pending, // Start as pending ); - _log.info('TransactionsProvider', - 'Transaction saved locally with ID: ${localTransaction.localId}'); + _log.info('TransactionsProvider', 'Transaction saved locally'); // Reload transactions to show the new one immediately await fetchTransactions(accessToken: accessToken, accountId: accountId); @@ -446,8 +451,10 @@ class TransactionsProvider with ChangeNotifier { required String localId, required SyncStatus syncStatus, }) async { - _log.info('TransactionsProvider', - 'Undoing transaction $localId with status $syncStatus'); + _log.info( + 'TransactionsProvider', + 'Undoing transaction with status $syncStatus', + ); try { final success = diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart index b5cbf5519..a3c1afe96 100644 --- a/mobile/lib/screens/backend_config_screen.dart +++ b/mobile/lib/screens/backend_config_screen.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../models/custom_proxy_header.dart'; import '../services/api_config.dart'; import '../services/custom_proxy_headers_service.dart'; +import '../services/log_service.dart'; import '../widgets/custom_proxy_headers_editor.dart'; class BackendConfigScreen extends StatefulWidget { @@ -47,10 +48,13 @@ class _BackendConfigScreenState extends State { if (savedUrl != null && savedUrl.isNotEmpty) { urlToShow = savedUrl; } - } catch (e, stack) { + } catch (e) { // Swallow storage failures so the screen still becomes interactive with // sensible defaults; the user can re-enter and re-save. - debugPrint('BackendConfigScreen: failed to load saved config: $e\n$stack'); + LogService.instance.warning( + 'BackendConfigScreen', + 'Failed to load saved backend config: $e', + ); } finally { if (mounted) { setState(() { @@ -75,9 +79,9 @@ class _BackendConfigScreenState extends State { try { // Normalize base URL by removing trailing slashes final normalizedUrl = _urlController.text.trim().replaceAll( - RegExp(r'/+$'), - '', - ); + RegExp(r'/+$'), + '', + ); // Apply the unsaved edits only for the duration of this probe so the // test reflects what the user is about to save. Restored in `finally`. @@ -85,23 +89,21 @@ class _BackendConfigScreenState extends State { // Check /sessions/new page to verify it's a Sure backend final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new'); - final sessionsResponse = await http - .get(sessionsUrl, headers: ApiConfig.htmlHeaders()) - .timeout( - const Duration(seconds: 10), - onTimeout: () { - throw Exception( - 'Connection timeout. Please check the URL and try again.', - ); - }, + final sessionsResponse = + await http.get(sessionsUrl, headers: ApiConfig.htmlHeaders()).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw Exception( + 'Connection timeout. Please check the URL and try again.', ); + }, + ); if (sessionsResponse.statusCode >= 200 && sessionsResponse.statusCode < 400) { if (mounted) { setState(() { - _successMessage = - 'Connection successful!'; + _successMessage = 'Connection successful!'; }); } } else { @@ -139,9 +141,9 @@ class _BackendConfigScreenState extends State { try { // Normalize base URL by removing trailing slashes final normalizedUrl = _urlController.text.trim().replaceAll( - RegExp(r'/+$'), - '', - ); + RegExp(r'/+$'), + '', + ); // Save URL to SharedPreferences final prefs = await SharedPreferences.getInstance(); @@ -223,17 +225,17 @@ class _BackendConfigScreenState extends State { Text( 'Configuration', style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( 'Update your Sure server URL', style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + color: colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), const SizedBox(height: 48), @@ -430,8 +432,8 @@ class _BackendConfigScreenState extends State { Text( 'You can change this later in the settings.', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + color: colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ], diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart index 65ccc335c..2487f9e9c 100644 --- a/mobile/lib/screens/calendar_screen.dart +++ b/mobile/lib/screens/calendar_screen.dart @@ -51,7 +51,8 @@ class _CalendarScreenState extends State { // Select first account of the selected type final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts); setState(() { - _selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null; + _selectedAccount = + filteredAccounts.isNotEmpty ? filteredAccounts.first : null; }); if (_selectedAccount != null) { await _loadTransactionsForAccount(); @@ -87,13 +88,17 @@ class _CalendarScreenState extends State { ); final transactions = transactionsProvider.transactions; - _log.info('CalendarScreen', 'Loaded ${transactions.length} transactions for account ${_selectedAccount!.name}'); + _log.info( + 'CalendarScreen', + 'Loaded ${transactions.length} transactions for selected account', + ); // Store transactions for date filtering _transactions = List.from(transactions); _calculateDailyChanges(transactions); - _log.info('CalendarScreen', 'Calculated ${_dailyChanges.length} days with changes'); + _log.info('CalendarScreen', + 'Calculated ${_dailyChanges.length} days with changes'); } setState(() { @@ -104,7 +109,8 @@ class _CalendarScreenState extends State { void _calculateDailyChanges(List transactions) { final changes = {}; - _log.debug('CalendarScreen', 'Starting to calculate daily changes for ${transactions.length} transactions'); + _log.debug('CalendarScreen', + 'Starting to calculate daily changes for ${transactions.length} transactions'); for (var transaction in transactions) { try { @@ -115,23 +121,24 @@ class _CalendarScreenState extends State { // For asset accounts, flip the sign to match accounting conventions // For liability accounts, also flip the sign - if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) { + if (_selectedAccount?.isAsset == true || + _selectedAccount?.isLiability == true) { amount = -amount; } - _log.debug('CalendarScreen', 'Processing transaction date: $dateKey, parsed amount sign adjusted'); - changes[dateKey] = (changes[dateKey] ?? 0.0) + amount; - _log.debug('CalendarScreen', 'Date $dateKey now has total: ${changes[dateKey]}'); - } catch (_) { - _log.error('CalendarScreen', 'Failed to process transaction for calendar'); + } catch (e) { + final sanitizedError = LogService.sanitize(e.toString()); + final errorSummary = sanitizedError.length > 120 + ? '${sanitizedError.substring(0, 120)}...' + : sanitizedError; + _log.error('CalendarScreen', + 'Failed to process transaction for calendar: $errorSummary'); } } - _log.info('CalendarScreen', 'Final changes map has ${changes.length} entries'); - changes.forEach((date, amount) { - _log.debug('CalendarScreen', '$date -> $amount'); - }); + _log.info( + 'CalendarScreen', 'Final changes map has ${changes.length} entries'); setState(() { _dailyChanges = changes; @@ -172,7 +179,8 @@ class _CalendarScreenState extends State { return _transactions.where((transaction) { try { final transactionDate = DateTime.parse(transaction.date); - final transactionDateKey = DateFormat('yyyy-MM-dd').format(transactionDate); + final transactionDateKey = + DateFormat('yyyy-MM-dd').format(transactionDate); return transactionDateKey == dateKey; } catch (e) { return false; @@ -237,7 +245,8 @@ class _CalendarScreenState extends State { } // For asset accounts, flip the sign interpretation - if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) { + if (_selectedAccount?.isAsset == true || + _selectedAccount?.isLiability == true) { isNegative = !isNegative; } @@ -321,8 +330,8 @@ class _CalendarScreenState extends State { Text( 'Account Type', style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 8), SegmentedButton( @@ -343,11 +352,15 @@ class _CalendarScreenState extends State { setState(() { _accountType = newSelection.first; // Switch to first account of new type - final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts); - _selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null; + final filteredAccounts = + _getFilteredAccounts(accountsProvider.accounts); + _selectedAccount = filteredAccounts.isNotEmpty + ? filteredAccounts.first + : null; _dailyChanges = {}; _transactions = []; - _selectedDate = null; // Clear selection when changing account type + _selectedDate = + null; // Clear selection when changing account type }); if (_selectedAccount != null) { _loadTransactionsForAccount(); @@ -382,7 +395,8 @@ class _CalendarScreenState extends State { vertical: 12, ), ), - items: _getFilteredAccounts(accountsProvider.accounts).map((account) { + items: _getFilteredAccounts(accountsProvider.accounts) + .map((account) { return DropdownMenuItem( value: account, child: Text('${account.name} (${account.currency})'), @@ -453,11 +467,11 @@ class _CalendarScreenState extends State { Text( _formatCurrency(_getTotalForMonth()), style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: _getTotalForMonth() >= 0 - ? Colors.green - : Colors.red, - fontWeight: FontWeight.bold, - ), + color: _getTotalForMonth() >= 0 + ? Colors.green + : Colors.red, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -475,8 +489,10 @@ class _CalendarScreenState extends State { } Widget _buildCalendar(ColorScheme colorScheme) { - final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month, 1); - final lastDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month + 1, 0); + final firstDayOfMonth = + DateTime(_currentMonth.year, _currentMonth.month, 1); + final lastDayOfMonth = + DateTime(_currentMonth.year, _currentMonth.month + 1, 0); final daysInMonth = lastDayOfMonth.day; final startWeekday = firstDayOfMonth.weekday % 7; // 0 = Sunday @@ -506,18 +522,21 @@ class _CalendarScreenState extends State { ), // Calendar grid - ...List.generate((daysInMonth + startWeekday + 6) ~/ 7, (weekIndex) { + ...List.generate((daysInMonth + startWeekday + 6) ~/ 7, + (weekIndex) { return SizedBox( height: 70, child: Row( children: List.generate(7, (dayIndex) { - final dayNumber = weekIndex * 7 + dayIndex - startWeekday + 1; + final dayNumber = + weekIndex * 7 + dayIndex - startWeekday + 1; if (dayNumber < 1 || dayNumber > daysInMonth) { return const Expanded(child: SizedBox.shrink()); } - final date = DateTime(_currentMonth.year, _currentMonth.month, dayNumber); + final date = DateTime( + _currentMonth.year, _currentMonth.month, dayNumber); final dateKey = DateFormat('yyyy-MM-dd').format(date); final change = _dailyChanges[dateKey] ?? 0.0; final hasChange = _dailyChanges.containsKey(dateKey); @@ -541,7 +560,8 @@ class _CalendarScreenState extends State { ); } - Widget _buildDayCell(DateTime date, int day, double change, bool hasChange, ColorScheme colorScheme) { + Widget _buildDayCell(DateTime date, int day, double change, bool hasChange, + ColorScheme colorScheme) { Color? backgroundColor; Color? textColor; @@ -569,7 +589,9 @@ class _CalendarScreenState extends State { color: backgroundColor ?? colorScheme.surface, borderRadius: BorderRadius.circular(8), border: Border.all( - color: isSelected ? Theme.of(context).primaryColor : colorScheme.outlineVariant, + color: isSelected + ? Theme.of(context).primaryColor + : colorScheme.outlineVariant, width: isSelected ? 3 : 1, ), ), diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 137d8a00b..316b8d400 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -35,6 +35,11 @@ class _SettingsScreenState extends State { bool _isTogglingBiometric = false; List _customHeaders = []; + String _displayInitial(String? displayName) { + final trimmed = displayName?.trim() ?? ''; + return trimmed.isEmpty ? 'U' : trimmed[0].toUpperCase(); + } + @override void initState() { super.initState(); @@ -86,7 +91,7 @@ class _SettingsScreenState extends State { if (mounted) { final build = packageInfo.buildNumber; final display = build.isNotEmpty - ? '${packageInfo.version} (${build})' + ? '${packageInfo.version} ($build)' : packageInfo.version; setState(() => _appVersion = display); } @@ -107,8 +112,11 @@ class _SettingsScreenState extends State { if (mounted) { setState(() => _customHeaders = headers); } - } catch (e, stack) { - debugPrint('SettingsScreen: failed to load custom headers: $e\n$stack'); + } catch (e) { + LogService.instance.warning( + 'SettingsScreen', + 'Failed to load custom headers: $e', + ); // Keep the existing _customHeaders state so the screen remains usable. } } @@ -119,10 +127,9 @@ class _SettingsScreenState extends State { builder: (context) => AlertDialog( title: const Text('Clear Local Data'), content: const Text( - 'This will delete all locally cached transactions and accounts. ' - 'Your data on the server will not be affected. ' - 'Are you sure you want to continue?' - ), + 'This will delete all locally cached transactions and accounts. ' + 'Your data on the server will not be affected. ' + 'Are you sure you want to continue?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -156,7 +163,8 @@ class _SettingsScreenState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Local data cleared successfully. Pull to refresh to sync from server.'), + content: Text( + 'Local data cleared successfully. Pull to refresh to sync from server.'), backgroundColor: Colors.green, duration: Duration(seconds: 3), ), @@ -242,7 +250,8 @@ class _SettingsScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Account reset has been initiated. This may take a moment.'), + content: Text( + 'Account reset has been initiated. This may take a moment.'), backgroundColor: Colors.green, ), ); @@ -298,7 +307,8 @@ class _SettingsScreenState extends State { return; } - final result = await UserService().deleteAccount(accessToken: accessToken); + final result = + await UserService().deleteAccount(accessToken: accessToken); if (!context.mounted) return; @@ -344,7 +354,8 @@ class _SettingsScreenState extends State { Future _showCustomHeadersDialog() async { final formKey = GlobalKey(); - final latestHeaders = await CustomProxyHeadersService.instance.loadHeaders(); + final latestHeaders = + await CustomProxyHeadersService.instance.loadHeaders(); if (!mounted) return; setState(() => _customHeaders = latestHeaders); @@ -438,7 +449,7 @@ class _SettingsScreenState extends State { radius: 30, backgroundColor: colorScheme.primary, child: Text( - authProvider.user?.displayName[0].toUpperCase() ?? 'U', + _displayInitial(authProvider.user?.displayName), style: TextStyle( fontSize: 24, color: colorScheme.onPrimary, @@ -453,9 +464,12 @@ class _SettingsScreenState extends State { children: [ Text( authProvider.user?.displayName ?? 'User', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 4), Text( @@ -512,7 +526,8 @@ class _SettingsScreenState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute(builder: (context) => const LogViewerScreen()), + MaterialPageRoute( + builder: (context) => const LogViewerScreen()), ); }, ), @@ -570,7 +585,8 @@ class _SettingsScreenState extends State { ), ], selected: {themeProvider.themeMode}, - onSelectionChanged: (modes) => themeProvider.setThemeMode(modes.first), + onSelectionChanged: (modes) => + themeProvider.setThemeMode(modes.first), showSelectedIcon: false, ), ); @@ -627,7 +643,6 @@ class _SettingsScreenState extends State { if (_biometricSupported) ...[ const Divider(), - const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( @@ -639,11 +654,11 @@ class _SettingsScreenState extends State { ), ), ), - SwitchListTile( secondary: const Icon(Icons.fingerprint), title: const Text('Biometric Lock'), - subtitle: const Text('Require biometric authentication when resuming the app'), + subtitle: const Text( + 'Require biometric authentication when resuming the app'), value: _biometricEnabled, onChanged: _isTogglingBiometric ? null : _toggleBiometric, ), @@ -671,10 +686,15 @@ class _SettingsScreenState extends State { 'Delete all accounts, categories, merchants, and tags but keep your user account', ), trailing: _isResettingAccount - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) : null, enabled: !_isResettingAccount && !_isDeletingAccount, - onTap: _isResettingAccount || _isDeletingAccount ? null : () => _handleResetAccount(context), + onTap: _isResettingAccount || _isDeletingAccount + ? null + : () => _handleResetAccount(context), ), ListTile( @@ -684,10 +704,15 @@ class _SettingsScreenState extends State { 'Permanently remove all your data. This cannot be undone.', ), trailing: _isDeletingAccount - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) : null, enabled: !_isDeletingAccount && !_isResettingAccount, - onTap: _isDeletingAccount || _isResettingAccount ? null : () => _handleDeleteAccount(context), + onTap: _isDeletingAccount || _isResettingAccount + ? null + : () => _handleDeleteAccount(context), ), const Divider(), diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index e39ae6254..2a419b914 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -15,6 +15,28 @@ class AuthService { static const String _apiKeyKey = 'api_key'; static const String _authModeKey = 'auth_mode'; + void _logAuthException(String operation, Object error) { + LogService.instance.error( + 'AuthService', + '$operation failed with ${error.runtimeType}', + ); + } + + String _responseError(Map responseData, String fallback) { + final error = responseData['error']; + if (error is String && error.isNotEmpty) return error; + + final errors = responseData['errors']; + if (errors is List) { + final joined = errors.whereType().join(', '); + if (joined.isNotEmpty) return joined; + } else if (errors is String && errors.isNotEmpty) { + return errors; + } + + return fallback; + } + Future> login({ required String email, required String password, @@ -34,14 +56,18 @@ class AuthService { body['otp_code'] = otpCode; } - final response = await http.post( - url, - headers: ApiConfig.jsonHeaders(), - body: jsonEncode(body), - ).timeout(const Duration(seconds: 30)); + final response = await http + .post( + url, + headers: ApiConfig.jsonHeaders(), + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); - LogService.instance.debug('AuthService', 'Login response status: ${response.statusCode}'); - LogService.instance.debug('AuthService', 'Login response body: ${response.body}'); + LogService.instance.debug( + 'AuthService', + 'Login response received with status ${response.statusCode}', + ); final responseData = jsonDecode(response.body); @@ -54,7 +80,7 @@ class AuthService { User? user; if (responseData['user'] != null) { final rawUser = responseData['user']; - _logRawUserPayload('login', rawUser); + _logUserPayloadShape('login', rawUser); user = User.fromJson(rawUser); await _saveUser(user); } @@ -64,7 +90,8 @@ class AuthService { 'tokens': tokens, 'user': user, }; - } else if (response.statusCode == 401 && responseData['mfa_required'] == true) { + } else if (response.statusCode == 401 && + responseData['mfa_required'] == true) { return { 'success': false, 'mfa_required': true, @@ -73,41 +100,41 @@ class AuthService { } else { return { 'success': false, - 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Login failed', + 'error': _responseError(responseData, 'Login failed'), }; } - } on SocketException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Login SocketException: $e\n$stackTrace'); + } on SocketException catch (e) { + _logAuthException('Login', e); return { 'success': false, 'error': 'Network unavailable', }; - } on TimeoutException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Login TimeoutException: $e\n$stackTrace'); + } on TimeoutException catch (e) { + _logAuthException('Login', e); return { 'success': false, 'error': 'Request timed out', }; - } on HttpException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Login HttpException: $e\n$stackTrace'); + } on HttpException catch (e) { + _logAuthException('Login', e); return { 'success': false, 'error': 'Invalid response from server', }; - } on FormatException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Login FormatException: $e\n$stackTrace'); + } on FormatException catch (e) { + _logAuthException('Login', e); return { 'success': false, 'error': 'Invalid response from server', }; - } on TypeError catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Login TypeError: $e\n$stackTrace'); + } on TypeError catch (e) { + _logAuthException('Login', e); return { 'success': false, 'error': 'Invalid response from server', }; - } catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Login unexpected error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('Login', e); return { 'success': false, 'error': 'An unexpected error occurred', @@ -140,11 +167,13 @@ class AuthService { body['invite_code'] = inviteCode; } - final response = await http.post( - url, - headers: ApiConfig.jsonHeaders(), - body: jsonEncode(body), - ).timeout(const Duration(seconds: 30)); + final response = await http + .post( + url, + headers: ApiConfig.jsonHeaders(), + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); final responseData = jsonDecode(response.body); @@ -157,7 +186,7 @@ class AuthService { User? user; if (responseData['user'] != null) { final rawUser = responseData['user']; - _logRawUserPayload('signup', rawUser); + _logUserPayloadShape('signup', rawUser); user = User.fromJson(rawUser); await _saveUser(user); } @@ -170,41 +199,41 @@ class AuthService { } else { return { 'success': false, - 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Signup failed', + 'error': _responseError(responseData, 'Signup failed'), }; } - } on SocketException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Signup SocketException: $e\n$stackTrace'); + } on SocketException catch (e) { + _logAuthException('Signup', e); return { 'success': false, 'error': 'Network unavailable', }; - } on TimeoutException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Signup TimeoutException: $e\n$stackTrace'); + } on TimeoutException catch (e) { + _logAuthException('Signup', e); return { 'success': false, 'error': 'Request timed out', }; - } on HttpException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Signup HttpException: $e\n$stackTrace'); + } on HttpException catch (e) { + _logAuthException('Signup', e); return { 'success': false, 'error': 'Invalid response from server', }; - } on FormatException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Signup FormatException: $e\n$stackTrace'); + } on FormatException catch (e) { + _logAuthException('Signup', e); return { 'success': false, 'error': 'Invalid response from server', }; - } on TypeError catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Signup TypeError: $e\n$stackTrace'); + } on TypeError catch (e) { + _logAuthException('Signup', e); return { 'success': false, 'error': 'Invalid response from server', }; - } catch (e, stackTrace) { - LogService.instance.error('AuthService', 'Signup unexpected error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('Signup', e); return { 'success': false, 'error': 'An unexpected error occurred', @@ -219,14 +248,16 @@ class AuthService { try { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/refresh'); - final response = await http.post( - url, - headers: ApiConfig.jsonHeaders(), - body: jsonEncode({ - 'refresh_token': refreshToken, - 'device': deviceInfo, - }), - ).timeout(const Duration(seconds: 30)); + final response = await http + .post( + url, + headers: ApiConfig.jsonHeaders(), + body: jsonEncode({ + 'refresh_token': refreshToken, + 'device': deviceInfo, + }), + ) + .timeout(const Duration(seconds: 30)); final responseData = jsonDecode(response.body); @@ -244,38 +275,38 @@ class AuthService { 'error': responseData['error'] ?? 'Token refresh failed', }; } - } on SocketException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'RefreshToken SocketException: $e\n$stackTrace'); + } on SocketException catch (e) { + _logAuthException('RefreshToken', e); return { 'success': false, 'error': 'Network unavailable', }; - } on TimeoutException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'RefreshToken TimeoutException: $e\n$stackTrace'); + } on TimeoutException catch (e) { + _logAuthException('RefreshToken', e); return { 'success': false, 'error': 'Request timed out', }; - } on HttpException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'RefreshToken HttpException: $e\n$stackTrace'); + } on HttpException catch (e) { + _logAuthException('RefreshToken', e); return { 'success': false, 'error': 'Invalid response from server', }; - } on FormatException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'RefreshToken FormatException: $e\n$stackTrace'); + } on FormatException catch (e) { + _logAuthException('RefreshToken', e); return { 'success': false, 'error': 'Invalid response from server', }; - } on TypeError catch (e, stackTrace) { - LogService.instance.error('AuthService', 'RefreshToken TypeError: $e\n$stackTrace'); + } on TypeError catch (e) { + _logAuthException('RefreshToken', e); return { 'success': false, 'error': 'Invalid response from server', }; - } catch (e, stackTrace) { - LogService.instance.error('AuthService', 'RefreshToken unexpected error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('RefreshToken', e); return { 'success': false, 'error': 'An unexpected error occurred', @@ -298,7 +329,8 @@ class AuthService { }, ).timeout(const Duration(seconds: 30)); - LogService.instance.debug('AuthService', 'API key login response status: ${response.statusCode}'); + LogService.instance.debug('AuthService', + 'API key login response status: ${response.statusCode}'); if (response.statusCode == 200) { await _saveApiKey(apiKey); @@ -316,20 +348,20 @@ class AuthService { 'error': 'Login failed (status ${response.statusCode})', }; } - } on SocketException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'API key login SocketException: $e\n$stackTrace'); + } on SocketException catch (e) { + _logAuthException('API key login', e); return { 'success': false, 'error': 'Network unavailable', }; - } on TimeoutException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'API key login TimeoutException: $e\n$stackTrace'); + } on TimeoutException catch (e) { + _logAuthException('API key login', e); return { 'success': false, 'error': 'Request timed out', }; - } catch (e, stackTrace) { - LogService.instance.error('AuthService', 'API key login unexpected error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('API key login', e); return { 'success': false, 'error': 'An unexpected error occurred', @@ -388,11 +420,13 @@ class AuthService { // Exchange authorization code for tokens via secure POST try { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_exchange'); - final response = await http.post( - url, - headers: ApiConfig.jsonHeaders(), - body: jsonEncode({'code': code}), - ).timeout(const Duration(seconds: 30)); + final response = await http + .post( + url, + headers: ApiConfig.jsonHeaders(), + body: jsonEncode({'code': code}), + ) + .timeout(const Duration(seconds: 30)); if (response.statusCode != 200) { final errorData = jsonDecode(response.body); @@ -413,7 +447,7 @@ class AuthService { }); await _saveTokens(tokens); - _logRawUserPayload('sso_exchange', data['user']); + _logUserPayloadShape('sso_exchange', data['user']); final user = User.fromJson(data['user']); await _saveUser(user); @@ -422,20 +456,20 @@ class AuthService { 'tokens': tokens, 'user': user, }; - } on SocketException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'SSO exchange SocketException: $e\n$stackTrace'); + } on SocketException catch (e) { + _logAuthException('SSO exchange', e); return { 'success': false, 'error': 'Network unavailable', }; - } on TimeoutException catch (e, stackTrace) { - LogService.instance.error('AuthService', 'SSO exchange TimeoutException: $e\n$stackTrace'); + } on TimeoutException catch (e) { + _logAuthException('SSO exchange', e); return { 'success': false, 'error': 'Request timed out', }; - } catch (e, stackTrace) { - LogService.instance.error('AuthService', 'SSO exchange unexpected error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('SSO exchange', e); return { 'success': false, 'error': 'Failed to exchange authorization code', @@ -450,15 +484,17 @@ class AuthService { }) async { try { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link'); - final response = await http.post( - url, - headers: ApiConfig.jsonHeaders(), - body: jsonEncode({ - 'linking_code': linkingCode, - 'email': email, - 'password': password, - }), - ).timeout(const Duration(seconds: 30)); + final response = await http + .post( + url, + headers: ApiConfig.jsonHeaders(), + body: jsonEncode({ + 'linking_code': linkingCode, + 'email': email, + 'password': password, + }), + ) + .timeout(const Duration(seconds: 30)); final responseData = jsonDecode(response.body); @@ -468,7 +504,7 @@ class AuthService { User? user; if (responseData['user'] != null) { - _logRawUserPayload('sso_link', responseData['user']); + _logUserPayloadShape('sso_link', responseData['user']); user = User.fromJson(responseData['user']); await _saveUser(user); } @@ -481,15 +517,17 @@ class AuthService { } else { return { 'success': false, - 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account linking failed', + 'error': _responseError(responseData, 'Account linking failed'), }; } - } on SocketException { + } on SocketException catch (e) { + _logAuthException('SSO link', e); return {'success': false, 'error': 'Network unavailable'}; - } on TimeoutException { + } on TimeoutException catch (e) { + _logAuthException('SSO link', e); return {'success': false, 'error': 'Request timed out'}; - } catch (e, stackTrace) { - LogService.instance.error('AuthService', 'SSO link error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('SSO link', e); return {'success': false, 'error': 'Failed to link account'}; } } @@ -500,18 +538,21 @@ class AuthService { String? lastName, }) async { try { - final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account'); + final url = + Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account'); final body = { 'linking_code': linkingCode, }; if (firstName != null) body['first_name'] = firstName; if (lastName != null) body['last_name'] = lastName; - final response = await http.post( - url, - headers: ApiConfig.jsonHeaders(), - body: jsonEncode(body), - ).timeout(const Duration(seconds: 30)); + final response = await http + .post( + url, + headers: ApiConfig.jsonHeaders(), + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); final responseData = jsonDecode(response.body); @@ -521,7 +562,7 @@ class AuthService { User? user; if (responseData['user'] != null) { - _logRawUserPayload('sso_create_account', responseData['user']); + _logUserPayloadShape('sso_create_account', responseData['user']); user = User.fromJson(responseData['user']); await _saveUser(user); } @@ -534,15 +575,17 @@ class AuthService { } else { return { 'success': false, - 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account creation failed', + 'error': _responseError(responseData, 'Account creation failed'), }; } - } on SocketException { + } on SocketException catch (e) { + _logAuthException('SSO create account', e); return {'success': false, 'error': 'Network unavailable'}; - } on TimeoutException { + } on TimeoutException catch (e) { + _logAuthException('SSO create account', e); return {'success': false, 'error': 'Request timed out'}; - } catch (e, stackTrace) { - LogService.instance.error('AuthService', 'SSO create account error: $e\n$stackTrace'); + } catch (e) { + _logAuthException('SSO create account', e); return {'success': false, 'error': 'Failed to create account'}; } } @@ -563,7 +606,7 @@ class AuthService { final responseData = jsonDecode(response.body); if (response.statusCode == 200) { - _logRawUserPayload('enable_ai', responseData['user']); + _logUserPayloadShape('enable_ai', responseData['user']); final user = User.fromJson(responseData['user']); await _saveUser(user); return { @@ -574,12 +617,13 @@ class AuthService { return { 'success': false, - 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Failed to enable AI', + 'error': _responseError(responseData, 'Failed to enable AI'), }; } catch (e) { + _logAuthException('Enable AI', e); return { 'success': false, - 'error': 'Network error: ${e.toString()}', + 'error': 'Network error', }; } } @@ -594,7 +638,7 @@ class AuthService { Future getStoredTokens() async { final tokensJson = await _storage.read(key: _tokenKey); if (tokensJson == null) return null; - + try { return AuthTokens.fromJson(jsonDecode(tokensJson)); } catch (e) { @@ -605,7 +649,7 @@ class AuthService { Future getStoredUser() async { final userJson = await _storage.read(key: _userKey); if (userJson == null) return null; - + try { return User.fromJson(jsonDecode(userJson)); } catch (e) { @@ -627,20 +671,25 @@ class AuthService { ); } - void _logRawUserPayload(String source, dynamic userPayload) { + void _logUserPayloadShape(String source, dynamic userPayload) { if (userPayload == null) { - LogService.instance.debug('AuthService', '$source user payload: '); + LogService.instance.debug( + 'AuthService', + '$source user payload missing', + ); return; } if (userPayload is Map) { - try { - LogService.instance.debug('AuthService', '$source user payload: ${jsonEncode(userPayload)}'); - } catch (_) { - LogService.instance.debug('AuthService', '$source user payload: $userPayload'); - } + LogService.instance.debug( + 'AuthService', + '$source user payload received with ${userPayload.length} fields', + ); } else { - LogService.instance.debug('AuthService', '$source user payload type: ${userPayload.runtimeType}'); + LogService.instance.debug( + 'AuthService', + '$source user payload type: ${userPayload.runtimeType}', + ); } } diff --git a/mobile/lib/services/database_helper.dart b/mobile/lib/services/database_helper.dart index d0145edba..91741adb3 100644 --- a/mobile/lib/services/database_helper.dart +++ b/mobile/lib/services/database_helper.dart @@ -201,8 +201,7 @@ class DatabaseHelper { return localId; } final db = await database; - _log.debug('DatabaseHelper', - 'Inserting transaction: local_id=${transaction['local_id']}, account_id="${transaction['account_id']}", server_id=${transaction['server_id']}'); + _log.debug('DatabaseHelper', 'Inserting transaction into local database'); await db.insert( 'transactions', transaction, @@ -235,8 +234,7 @@ class DatabaseHelper { final db = await database; if (accountId != null) { - _log.debug('DatabaseHelper', - 'Querying transactions WHERE account_id = "$accountId"'); + _log.debug('DatabaseHelper', 'Querying scoped transactions'); final results = await db.query( 'transactions', where: 'account_id = ?', diff --git a/mobile/lib/services/log_service.dart b/mobile/lib/services/log_service.dart index 83c0a389d..ebe66372c 100644 --- a/mobile/lib/services/log_service.dart +++ b/mobile/lib/services/log_service.dart @@ -32,17 +32,88 @@ class LogService with ChangeNotifier { List get logs => List.unmodifiable(_logs); + static final List _authPatterns = [ + RegExp( + r'\b(authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|bearer|password|otp[-_]?code|linking[-_]?code|secret|custom[-_]?proxy[-_]?headers?)\b\s*[:=]\s*(Bearer\s+[A-Za-z0-9._~+/=-]+|"[^"]*"|[^\s,}]+)', + caseSensitive: false, + ), + RegExp( + r'"(authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|otp[-_]?code|linking[-_]?code|secret)"\s*:\s*("[^"]*"|[0-9.]+|true|false|null)', + caseSensitive: false, + ), + ]; + + static final List _businessDataPatterns = [ + RegExp( + r'\b(local[-_]?id|account[-_]?id|server[-_]?id|transaction[-_]?id|merchant[-_]?id|category[-_]?id|tag[-_]?ids?|user[-_]?id|backend[-_]?url|base[-_]?url|amount|account[-_]?name|merchant[-_]?name|category[-_]?name|display[-_]?name|transaction[-_]?name|email|first[-_]?name|last[-_]?name)\b\s*[:=]\s*("[^"]*"|[^\s,}]+)', + caseSensitive: false, + ), + RegExp( + r'"(local[-_]?id|account[-_]?id|server[-_]?id|transaction[-_]?id|merchant[-_]?id|category[-_]?id|tag[-_]?ids?|user[-_]?id|backend[-_]?url|base[-_]?url|amount|account[-_]?name|merchant[-_]?name|category[-_]?name|display[-_]?name|transaction[-_]?name|email|first[-_]?name|last[-_]?name)"\s*:\s*("[^"]*"|[0-9.]+|true|false|null)', + caseSensitive: false, + ), + ]; + static final List _sensitiveKeyPatterns = [ + ..._authPatterns, + ..._businessDataPatterns, + ]; + + static final RegExp _bearerTokenPattern = + RegExp(r'\bBearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false); + static final RegExp _urlPattern = RegExp(r'https?://[^\s,}]+'); + static final RegExp _hostLookupPattern = RegExp( + r'''(Failed host lookup:\s*)['"]?[^'"\s)]+['"]?''', + caseSensitive: false, + ); + static final RegExp _socketAddressPattern = RegExp( + r'\b(address|host)\s*=\s*([^\s,}]+)', + caseSensitive: false, + ); + static final RegExp _emailPattern = RegExp( + r'\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b', + caseSensitive: false); + static final RegExp _uuidPattern = RegExp( + r'\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', + caseSensitive: false, + ); + static final RegExp _longNumericIdPattern = RegExp(r'\b\d{14,}\b'); + + static String sanitize(String message) { + var sanitized = message; + + for (final pattern in _sensitiveKeyPatterns) { + sanitized = sanitized.replaceAllMapped(pattern, (match) { + final key = match.group(1) ?? 'value'; + return '$key=[redacted]'; + }); + } + + sanitized = sanitized + .replaceAll(_bearerTokenPattern, 'Bearer [redacted]') + .replaceAll(_urlPattern, '[url]') + .replaceAllMapped( + _hostLookupPattern, (match) => '${match.group(1)}[host]') + .replaceAllMapped( + _socketAddressPattern, (match) => '${match.group(1)}=[host]') + .replaceAll(_emailPattern, '[email]') + .replaceAll(_uuidPattern, '[id]') + .replaceAll(_longNumericIdPattern, '[id]'); + + return sanitized; + } + /// Call this when log viewer screen becomes active void setLogViewerActive(bool active) { _isLogViewerActive = active; } void log(String tag, String message, {String level = 'INFO'}) { + final sanitizedMessage = sanitize(message); final entry = LogEntry( timestamp: DateTime.now(), level: level, tag: tag, - message: message, + message: sanitizedMessage, ); _logs.add(entry); @@ -53,7 +124,7 @@ class LogService with ChangeNotifier { } // Also print to console for development - debugPrint('[$level][$tag] $message'); + debugPrint('[$level][$tag] $sanitizedMessage'); // Only notify listeners if log viewer is active to avoid unnecessary rebuilds if (_isLogViewerActive) { @@ -63,7 +134,8 @@ class LogService with ChangeNotifier { void debug(String tag, String message) => log(tag, message, level: 'DEBUG'); void info(String tag, String message) => log(tag, message, level: 'INFO'); - void warning(String tag, String message) => log(tag, message, level: 'WARNING'); + void warning(String tag, String message) => + log(tag, message, level: 'WARNING'); void error(String tag, String message) => log(tag, message, level: 'ERROR'); void clear() { @@ -73,8 +145,10 @@ class LogService with ChangeNotifier { String exportLogs() { final buffer = StringBuffer(); + // Log messages are sanitized before storage; export should preserve them. for (final log in _logs) { - buffer.writeln('${log.formattedTime} [${log.level}][${log.tag}] ${log.message}'); + buffer.writeln( + '${log.formattedTime} [${log.level}][${log.tag}] ${log.message}'); } return buffer.toString(); } diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart index 520de7763..cad906fb1 100644 --- a/mobile/lib/services/offline_storage_service.dart +++ b/mobile/lib/services/offline_storage_service.dart @@ -30,8 +30,10 @@ class OfflineStorageService { }) async { final localId = _uuid.v4(); - _log.info('OfflineStorage', - 'saveTransaction called: localId=$localId, accountId=$accountId, syncStatus=$syncStatus'); + _log.info( + 'OfflineStorage', + 'saveTransaction called with syncStatus=$syncStatus', + ); final transaction = OfflineTransaction( id: serverId, @@ -54,8 +56,7 @@ class OfflineStorageService { try { await _dbHelper.insertTransaction(transaction.toDatabaseMap()); - _log.info('OfflineStorage', - 'Transaction saved successfully with localId: $localId'); + _log.info('OfflineStorage', 'Transaction saved successfully'); return transaction; } catch (e) { _log.error('OfflineStorage', 'Failed to save transaction: $e'); @@ -65,21 +66,14 @@ class OfflineStorageService { Future> getTransactions({String? accountId}) async { _log.debug( - 'OfflineStorage', 'getTransactions called with accountId: $accountId'); + 'OfflineStorage', + 'getTransactions called${accountId != null ? " with account filter" : ""}', + ); final transactionMaps = await _dbHelper.getTransactions(accountId: accountId); _log.debug('OfflineStorage', 'Retrieved ${transactionMaps.length} transaction maps from database'); - if (transactionMaps.isNotEmpty && accountId != null) { - _log.debug('OfflineStorage', 'Sample transaction account_ids:'); - for (int i = 0; i < transactionMaps.take(3).length; i++) { - final map = transactionMaps[i]; - _log.debug('OfflineStorage', - ' - Transaction ${map['server_id']}: account_id="${map['account_id']}"'); - } - } - final transactions = transactionMaps .map((map) => OfflineTransaction.fromDatabaseMap(map)) .toList(); @@ -139,14 +133,15 @@ class OfflineStorageService { /// Mark a transaction for pending deletion (offline delete) Future markTransactionForDeletion(String serverId) async { - _log.info( - 'OfflineStorage', 'Marking transaction $serverId for pending deletion'); + _log.info('OfflineStorage', 'Marking transaction for pending deletion'); // Find the transaction by server ID final existing = await getTransactionByServerId(serverId); if (existing == null) { - _log.warning('OfflineStorage', - 'Transaction $serverId not found, cannot mark for deletion'); + _log.warning( + 'OfflineStorage', + 'Transaction not found, cannot mark for deletion', + ); return; } @@ -158,33 +153,34 @@ class OfflineStorageService { await _dbHelper.updateTransaction( existing.localId, updated.toDatabaseMap()); - _log.info('OfflineStorage', - 'Transaction ${existing.localId} marked as pending_delete'); + _log.info('OfflineStorage', 'Transaction marked as pending_delete'); } /// Undo a pending transaction operation (either pending create or pending delete) Future undoPendingTransaction( String localId, SyncStatus currentStatus) async { - _log.info('OfflineStorage', - 'Undoing pending transaction $localId with status $currentStatus'); + _log.info( + 'OfflineStorage', + 'Undoing pending transaction with status $currentStatus', + ); final existing = await getTransactionByLocalId(localId); if (existing == null) { - _log.warning( - 'OfflineStorage', 'Transaction $localId not found, cannot undo'); + _log.warning('OfflineStorage', 'Transaction not found, cannot undo'); return false; } if (currentStatus == SyncStatus.pending) { // For pending creates: delete the transaction completely - _log.info( - 'OfflineStorage', 'Deleting pending create transaction $localId'); + _log.info('OfflineStorage', 'Deleting pending create transaction'); await deleteTransaction(localId); return true; } else if (currentStatus == SyncStatus.pendingDelete) { // For pending deletes: restore to synced status - _log.info('OfflineStorage', - 'Restoring pending delete transaction $localId to synced'); + _log.info( + 'OfflineStorage', + 'Restoring pending delete transaction to synced', + ); final updated = existing.copyWith( syncStatus: SyncStatus.synced, updatedAt: DateTime.now(), @@ -203,13 +199,6 @@ class OfflineStorageService { _log.info('OfflineStorage', 'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server'); - // Log first transaction's accountId for debugging - if (serverTransactions.isNotEmpty) { - final firstTx = serverTransactions.first; - _log.info('OfflineStorage', - 'First transaction: id=${firstTx.id}, accountId="${firstTx.accountId}", name="${firstTx.name}"'); - } - // Use upsert logic instead of clear + insert to preserve recently uploaded transactions _log.info('OfflineStorage', 'Upserting all transactions from server (preserving pending/failed)'); @@ -251,8 +240,11 @@ class OfflineStorageService { // Log if transaction has empty accountId if (transaction.accountId.isEmpty) { - _log.warning('OfflineStorage', - 'Transaction ${transaction.id} has empty accountId from server! Provided accountId: $accountId, effective: $effectiveAccountId'); + final fallbackApplied = accountId != null && accountId.isNotEmpty; + _log.warning( + 'OfflineStorage', + 'Transaction has empty accountId from server; fallbackApplied=$fallbackApplied', + ); } // Check if we already have this transaction @@ -264,8 +256,10 @@ class OfflineStorageService { effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId; if (finalAccountId.isEmpty) { - _log.error('OfflineStorage', - 'CRITICAL: Updating transaction ${transaction.id} with EMPTY accountId!'); + _log.error( + 'OfflineStorage', + 'CRITICAL: Updating transaction with EMPTY accountId; operation=update fallbackApplied=false existingAccountIdPresent=false', + ); } final updated = existing.mergeServerTransaction( @@ -277,8 +271,10 @@ class OfflineStorageService { } else { // Insert new transaction if (effectiveAccountId.isEmpty) { - _log.error('OfflineStorage', - 'CRITICAL: Inserting transaction ${transaction.id} with EMPTY accountId!'); + _log.error( + 'OfflineStorage', + 'CRITICAL: Inserting transaction with EMPTY accountId; operation=insert fallbackApplied=false', + ); } final offlineTransaction = OfflineTransaction( diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index c40ef467e..702538749 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -30,7 +30,8 @@ class SyncService with ChangeNotifier { try { final pendingDeletes = await _offlineStorage.getPendingDeletes(); - _log.info('SyncService', 'Found ${pendingDeletes.length} pending deletes to process'); + _log.info('SyncService', + 'Found ${pendingDeletes.length} pending deletes to process'); if (pendingDeletes.isEmpty) { return SyncResult(success: true, syncedCount: 0); @@ -40,14 +41,16 @@ class SyncService with ChangeNotifier { try { // Only attempt to delete on server if the transaction has a server ID if (transaction.id != null && transaction.id!.isNotEmpty) { - _log.info('SyncService', 'Deleting transaction ${transaction.id} from server'); + _log.info( + 'SyncService', 'Deleting pending transaction from server'); final result = await _transactionsService.deleteTransaction( accessToken: accessToken, transactionId: transaction.id!, ); if (result['success'] == true) { - _log.info('SyncService', 'Delete success! Removing from local storage'); + _log.info( + 'SyncService', 'Delete success! Removing from local storage'); // Delete from local storage completely await _offlineStorage.deleteTransaction(transaction.localId); successCount++; @@ -63,7 +66,10 @@ class SyncService with ChangeNotifier { } } else { // No server ID means it was never synced to server, just delete locally - _log.info('SyncService', 'Transaction ${transaction.localId} has no server ID, deleting locally only'); + _log.info( + 'SyncService', + 'Pending delete has no server ID, deleting locally only', + ); await _offlineStorage.deleteTransaction(transaction.localId); successCount++; } @@ -79,7 +85,8 @@ class SyncService with ChangeNotifier { } } - _log.info('SyncService', 'Delete complete: $successCount success, $failureCount failed'); + _log.info('SyncService', + 'Delete complete: $successCount success, $failureCount failed'); return SyncResult( success: failureCount == 0, @@ -99,14 +106,17 @@ class SyncService with ChangeNotifier { } /// Sync pending transactions to server (internal method without sync lock check) - Future _syncPendingTransactionsInternal(String accessToken) async { + Future _syncPendingTransactionsInternal( + String accessToken) async { int successCount = 0; int failureCount = 0; String? lastError; try { - final pendingTransactions = await _offlineStorage.getPendingTransactions(); - _log.info('SyncService', 'Found ${pendingTransactions.length} pending transactions to upload'); + final pendingTransactions = + await _offlineStorage.getPendingTransactions(); + _log.info('SyncService', + 'Found ${pendingTransactions.length} pending transactions to upload'); if (pendingTransactions.isEmpty) { return SyncResult(success: true, syncedCount: 0); @@ -114,7 +124,7 @@ class SyncService with ChangeNotifier { for (final transaction in pendingTransactions) { try { - _log.info('SyncService', 'Uploading transaction ${transaction.localId} (${transaction.name})'); + _log.info('SyncService', 'Uploading pending transaction'); // Upload transaction to server final result = await _transactionsService.createTransaction( accessToken: accessToken, @@ -133,7 +143,8 @@ class SyncService with ChangeNotifier { if (result['success'] == true) { // Update local transaction with server ID and mark as synced final serverTransaction = result['transaction'] as Transaction; - _log.info('SyncService', 'Upload success! Server ID: ${serverTransaction.id}'); + _log.info('SyncService', + 'Upload success; local transaction marked synced'); await _offlineStorage.updateTransactionSyncStatus( localId: transaction.localId, syncStatus: SyncStatus.synced, @@ -162,7 +173,8 @@ class SyncService with ChangeNotifier { } } - _log.info('SyncService', 'Upload complete: $successCount success, $failureCount failed'); + _log.info('SyncService', + 'Upload complete: $successCount success, $failureCount failed'); return SyncResult( success: failureCount == 0, @@ -221,7 +233,12 @@ class SyncService with ChangeNotifier { }) async { try { _log.info('SyncService', '========== SYNC FROM SERVER START =========='); - _log.info('SyncService', 'Fetching transactions from server (accountId: ${accountId ?? "ALL"})'); + _log.info( + 'SyncService', + accountId == null + ? 'Fetching transactions for all accounts' + : 'Fetching transactions for scoped account', + ); List allTransactions = []; int currentPage = 1; @@ -230,7 +247,8 @@ class SyncService with ChangeNotifier { // Fetch all pages while (currentPage <= totalPages) { - _log.info('SyncService', '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)'); + _log.info('SyncService', + '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)'); final result = await _transactionsService.getTransactions( accessToken: accessToken, @@ -239,15 +257,19 @@ class SyncService with ChangeNotifier { perPage: perPage, ); - _log.debug('SyncService', 'API call completed for page $currentPage, success: ${result['success']}'); + _log.debug('SyncService', + 'API call completed for page $currentPage, success: ${result['success']}'); if (result['success'] == true) { - final pageTransactions = (result['transactions'] as List?) - ?.cast() ?? []; + final pageTransactions = + (result['transactions'] as List?)?.cast() ?? + []; - _log.info('SyncService', 'Page $currentPage returned ${pageTransactions.length} transactions'); + _log.info('SyncService', + 'Page $currentPage returned ${pageTransactions.length} transactions'); allTransactions.addAll(pageTransactions); - _log.info('SyncService', 'Total transactions accumulated: ${allTransactions.length}'); + _log.info('SyncService', + 'Total transactions accumulated: ${allTransactions.length}'); // Extract pagination info if available final pagination = result['pagination'] as Map?; @@ -255,24 +277,35 @@ class SyncService with ChangeNotifier { final prevTotalPages = totalPages; totalPages = pagination['total_pages'] as int? ?? 1; final totalCount = pagination['total_count'] as int? ?? 0; - final currentPageFromApi = pagination['page'] as int? ?? currentPage; + final currentPageFromApi = + pagination['page'] as int? ?? currentPage; final perPageFromApi = pagination['per_page'] as int? ?? perPage; - _log.info('SyncService', 'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount'); + if (currentPageFromApi != currentPage) { + _log.warning('SyncService', + 'Pagination page mismatch: expected $currentPage, received $currentPageFromApi of $totalPages'); + } + + _log.info('SyncService', + 'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount'); if (prevTotalPages != totalPages) { - _log.info('SyncService', 'Total pages updated from $prevTotalPages to $totalPages'); + _log.info('SyncService', + 'Total pages updated from $prevTotalPages to $totalPages'); } } else { // No pagination info means this is the only page - _log.warning('SyncService', 'No pagination info in response - assuming single page'); + _log.warning('SyncService', + 'No pagination info in response - assuming single page'); totalPages = currentPage; } - _log.info('SyncService', 'Moving to next page (current: $currentPage, total: $totalPages)'); + _log.info('SyncService', + 'Moving to next page (current: $currentPage, total: $totalPages)'); currentPage++; } else { - _log.error('SyncService', 'Server returned error on page $currentPage: ${result['error']}'); + _log.error('SyncService', + 'Server returned error on page $currentPage: ${result['error']}'); return SyncResult( success: false, error: result['error'] as String? ?? 'Failed to sync from server', @@ -280,17 +313,23 @@ class SyncService with ChangeNotifier { } } - _log.info('SyncService', '>>> Pagination loop completed. Fetched ${currentPage - 1} pages'); - _log.info('SyncService', '>>> Received total of ${allTransactions.length} transactions from server'); + _log.info('SyncService', + '>>> Pagination loop completed. Fetched ${currentPage - 1} pages'); + _log.info('SyncService', + '>>> Received total of ${allTransactions.length} transactions from server'); // Update local cache with server data _log.info('SyncService', '========== UPDATING LOCAL CACHE =========='); if (accountId == null) { - _log.info('SyncService', 'Full sync - clearing and replacing all transactions'); + _log.info('SyncService', + 'Full sync - clearing and replacing all transactions'); // Full sync - replace all transactions await _offlineStorage.syncTransactionsFromServer(allTransactions); } else { - _log.info('SyncService', 'Partial sync - upserting ${allTransactions.length} transactions for account $accountId'); + _log.info( + 'SyncService', + 'Partial sync - upserting ${allTransactions.length} transactions', + ); // Partial sync - upsert transactions int upsertCount = 0; for (final transaction in allTransactions) { @@ -300,13 +339,16 @@ class SyncService with ChangeNotifier { ); upsertCount++; if (upsertCount % 50 == 0) { - _log.info('SyncService', 'Upserted $upsertCount/${allTransactions.length} transactions'); + _log.info('SyncService', + 'Upserted $upsertCount/${allTransactions.length} transactions'); } } - _log.info('SyncService', 'Completed upserting $upsertCount transactions'); + _log.info( + 'SyncService', 'Completed upserting $upsertCount transactions'); } - _log.info('SyncService', '========== SYNC FROM SERVER COMPLETE =========='); + _log.info( + 'SyncService', '========== SYNC FROM SERVER COMPLETE =========='); _lastSyncTime = DateTime.now(); notifyListeners(); @@ -326,7 +368,8 @@ class SyncService with ChangeNotifier { /// Sync accounts from server and update local cache Future syncAccounts(String accessToken) async { try { - final result = await _accountsService.getAccounts(accessToken: accessToken); + final result = + await _accountsService.getAccounts(accessToken: accessToken); if (result['success'] == true) { final accountsList = result['accounts'] as List? ?? []; @@ -374,17 +417,20 @@ class SyncService with ChangeNotifier { // Step 1: Process pending deletes (do this first to free up resources) _log.info('SyncService', 'Step 1: Processing pending deletes'); final deleteResult = await _syncPendingDeletesInternal(accessToken); - _log.info('SyncService', 'Step 1 complete: ${deleteResult.syncedCount ?? 0} deleted, ${deleteResult.failedCount ?? 0} failed'); + _log.info('SyncService', + 'Step 1 complete: ${deleteResult.syncedCount ?? 0} deleted, ${deleteResult.failedCount ?? 0} failed'); // Step 2: Upload pending transactions _log.info('SyncService', 'Step 2: Uploading pending transactions'); final uploadResult = await _syncPendingTransactionsInternal(accessToken); - _log.info('SyncService', 'Step 2 complete: ${uploadResult.syncedCount ?? 0} uploaded, ${uploadResult.failedCount ?? 0} failed'); + _log.info('SyncService', + 'Step 2 complete: ${uploadResult.syncedCount ?? 0} uploaded, ${uploadResult.failedCount ?? 0} failed'); // Step 3: Download transactions from server _log.info('SyncService', 'Step 3: Downloading transactions from server'); final downloadResult = await syncFromServer(accessToken: accessToken); - _log.info('SyncService', 'Step 3 complete: ${downloadResult.syncedCount ?? 0} downloaded'); + _log.info('SyncService', + 'Step 3 complete: ${downloadResult.syncedCount ?? 0} downloaded'); // Step 4: Sync accounts _log.info('SyncService', 'Step 4: Syncing accounts'); @@ -394,17 +440,29 @@ class SyncService with ChangeNotifier { _isSyncing = false; _lastSyncTime = DateTime.now(); - final allSuccess = deleteResult.success && uploadResult.success && downloadResult.success && accountsResult.success; - _syncError = allSuccess ? null : (deleteResult.error ?? uploadResult.error ?? downloadResult.error ?? accountsResult.error); + final allSuccess = deleteResult.success && + uploadResult.success && + downloadResult.success && + accountsResult.success; + _syncError = allSuccess + ? null + : (deleteResult.error ?? + uploadResult.error ?? + downloadResult.error ?? + accountsResult.error); - _log.info('SyncService', '==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ===='); + _log.info('SyncService', + '==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ===='); notifyListeners(); return SyncResult( success: allSuccess, - syncedCount: (deleteResult.syncedCount ?? 0) + (uploadResult.syncedCount ?? 0) + (downloadResult.syncedCount ?? 0), - failedCount: (deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0), + syncedCount: (deleteResult.syncedCount ?? 0) + + (uploadResult.syncedCount ?? 0) + + (downloadResult.syncedCount ?? 0), + failedCount: + (deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0), error: _syncError, ); } catch (e) { @@ -421,7 +479,8 @@ class SyncService with ChangeNotifier { } /// Auto sync if online - to be called when app regains connectivity - Future autoSync(String accessToken, ConnectivityService connectivityService) async { + Future autoSync( + String accessToken, ConnectivityService connectivityService) async { if (connectivityService.isOnline && !_isSyncing) { await performFullSync(accessToken); } diff --git a/mobile/test/services/auth_service_test.dart b/mobile/test/services/auth_service_test.dart new file mode 100644 index 000000000..f23e9ef79 --- /dev/null +++ b/mobile/test/services/auth_service_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/services/api_config.dart'; +import 'package:sure_mobile/services/auth_service.dart'; + +void main() { + group('AuthService', () { + late HttpServer server; + + setUp(() async { + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + ApiConfig.clearApiKeyAuth(); + ApiConfig.setCustomProxyHeaders([]); + ApiConfig.setBaseUrl('http://${server.address.host}:${server.port}'); + }); + + tearDown(() async { + ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl); + await server.close(force: true); + }); + + test('login handles string errors payloads without throwing', () async { + final subscription = server.listen((request) { + if (request.method != 'POST' || + request.uri.path != '/api/v1/auth/login') { + request.response.statusCode = 404; + request.response.close(); + return; + } + + request.response + ..statusCode = 422 + ..headers.contentType = ContentType.json + ..write(jsonEncode({'errors': 'Invalid login payload'})) + ..close(); + }); + addTearDown(subscription.cancel); + + final result = await AuthService().login( + email: 'user@example.test', + password: 'password', + deviceInfo: const {'platform': 'test'}, + ); + + expect(result['success'], false); + expect(result['error'], 'Invalid login payload'); + }); + }); +} diff --git a/mobile/test/services/log_service_test.dart b/mobile/test/services/log_service_test.dart new file mode 100644 index 000000000..303298a44 --- /dev/null +++ b/mobile/test/services/log_service_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/services/log_service.dart'; + +void main() { + setUp(() { + LogService.instance.clear(); + }); + + test('sanitize redacts authentication and identity values', () { + final sanitized = LogService.sanitize( + 'Authorization: Bearer secret-token ' + 'X-Api-Key=mobile-secret ' + 'email=user@example.com ' + 'accountId=123e4567-e89b-12d3-a456-426614174000 ' + 'backendUrl=https://sure.example.test/api', + ); + + expect(sanitized, isNot(contains('secret-token'))); + expect(sanitized, isNot(contains('mobile-secret'))); + expect(sanitized, isNot(contains('user@example.com'))); + expect(sanitized, isNot(contains('123e4567-e89b-12d3-a456-426614174000'))); + expect(sanitized, isNot(contains('sure.example.test'))); + expect(sanitized, contains('[redacted]')); + }); + + test('sanitize redacts host-only socket error backends', () { + final sanitized = LogService.sanitize( + "SocketException: Failed host lookup: 'private.internal' " + '(OS Error: nodename nor servname provided, errno = 8)', + ); + + expect(sanitized, contains('Failed host lookup: [host]')); + expect(sanitized, isNot(contains('private.internal'))); + + final unquoted = LogService.sanitize( + 'SocketException: Failed host lookup: private.internal ' + '(OS Error: lookup failed)', + ); + expect(unquoted, contains('Failed host lookup: [host]')); + expect(unquoted, isNot(contains('private.internal'))); + }); + + test('sanitize redacts financial and merchant values', () { + final sanitized = LogService.sanitize( + 'transactionName=Coffee amount=123.45 merchantName="Corner Store" ' + '"transaction_id":"txn_123","local_id":"local_123"', + ); + + expect(sanitized, contains('[redacted]')); + expect(sanitized, isNot(contains('Coffee'))); + expect(sanitized, isNot(contains('123.45'))); + expect(sanitized, isNot(contains('Corner Store'))); + expect(sanitized, isNot(contains('txn_123'))); + expect(sanitized, isNot(contains('local_123'))); + }); + + test('sanitize redacts long numeric ids but preserves shorter counts', () { + final longNumericId = LogService.sanitize('orderId=12345678901234'); + final shorterCount = LogService.sanitize('count=123456789012'); + final millisecondTimestamp = LogService.sanitize('timestamp=1717605296123'); + + expect(longNumericId, contains('orderId=[id]')); + expect(longNumericId, isNot(contains('12345678901234'))); + expect(shorterCount, contains('123456789012')); + expect(millisecondTimestamp, contains('timestamp=1717605296123')); + }); + + test('sanitize handles empty and safe messages', () { + expect(LogService.sanitize(''), ''); + + const message = 'Fetched 25 transactions on page 2 with syncStatus=pending ' + 'filename=main.dart pageName=transactions hostname=localhost ' + 'animationName=fadeIn'; + expect(LogService.sanitize(message), message); + }); + + test('sanitize redacts repeated sensitive values', () { + final sanitized = LogService.sanitize( + 'email=one@example.com email=two@example.com ' + 'Authorization: Bearer first-token Authorization: Bearer second-token', + ); + + expect(sanitized, isNot(contains('one@example.com'))); + expect(sanitized, isNot(contains('two@example.com'))); + expect(sanitized, isNot(contains('first-token'))); + expect(sanitized, isNot(contains('second-token'))); + expect( + '[redacted]'.allMatches(sanitized), hasLength(greaterThanOrEqualTo(4))); + }); + + test('auth service style errors redact credentials and backend values', () { + LogService.instance.error( + 'AuthService', + 'Login SocketException: Failed host lookup: ' + "'private.internal' password=secret otp_code=123456 " + 'Authorization: Bearer token-123 email=user@example.com ' + 'https://sure.example.test/login', + ); + + final exported = LogService.instance.exportLogs(); + expect(exported, isNot(contains('private.internal'))); + expect(exported, isNot(contains('secret'))); + expect(exported, isNot(contains('123456'))); + expect(exported, isNot(contains('token-123'))); + expect(exported, isNot(contains('user@example.com'))); + expect(exported, isNot(contains('sure.example.test'))); + }); + + test('log storage and export use sanitized messages', () { + LogService.instance.info( + 'Test', + 'Saved transaction transactionName=Coffee amount=9.99 email=user@example.com', + ); + + expect(LogService.instance.logs, hasLength(1)); + expect(LogService.instance.logs.single.message, isNot(contains('Coffee'))); + expect(LogService.instance.logs.single.message, isNot(contains('9.99'))); + expect(LogService.instance.logs.single.message, + isNot(contains('user@example.com'))); + + final exported = LogService.instance.exportLogs(); + expect(exported, isNot(contains('Coffee'))); + expect(exported, isNot(contains('9.99'))); + expect(exported, isNot(contains('user@example.com'))); + }); + + test('all log levels store sanitized messages', () { + LogService.instance.debug('Test', 'accessToken=debug-secret'); + LogService.instance.warning('Test', 'merchantName=Warning Store'); + LogService.instance.error('Test', 'backendUrl=https://sure.example.test'); + + final exported = LogService.instance.exportLogs(); + expect(exported, isNot(contains('debug-secret'))); + expect(exported, isNot(contains('Warning Store'))); + expect(exported, isNot(contains('sure.example.test'))); + }); + + test('export redacts long interleaved diagnostic messages', () { + final safeChunks = List.filled(50, 'sync page ok').join(' '); + LogService.instance.info( + 'Test', + '$safeChunks accountId=123e4567-e89b-12d3-a456-426614174000 ' + 'amount=123456.78 merchantName="Big Store" $safeChunks', + ); + + final exported = LogService.instance.exportLogs(); + expect(exported, contains('sync page ok')); + expect(exported, isNot(contains('123e4567-e89b-12d3-a456-426614174000'))); + expect(exported, isNot(contains('123456.78'))); + expect(exported, isNot(contains('Big Store'))); + }); + + test('sanitize preserves safe operational diagnostics', () { + final sanitized = LogService.sanitize( + 'Fetched 25 transactions on page 2 with syncStatus=pending ' + 'filename=main.dart pageName=transactions hostname=localhost', + ); + + expect(sanitized, contains('Fetched 25 transactions')); + expect(sanitized, contains('page 2')); + expect(sanitized, contains('syncStatus=pending')); + expect(sanitized, contains('filename=main.dart')); + expect(sanitized, contains('pageName=transactions')); + expect(sanitized, contains('hostname=localhost')); + }); +}