Skip to content

Comments

Feature/offline report queue#666

Closed
JohnPalmer wants to merge 3 commits intomainfrom
feature/offline-report-queue
Closed

Feature/offline report queue#666
JohnPalmer wants to merge 3 commits intomainfrom
feature/offline-report-queue

Conversation

@JohnPalmer
Copy link
Contributor

This pull request refactors report and background track syncing to support offline persistence and retries. It also restores device-side background track masking (even though the tracks are also being masked by the API), and it includes documentation showing history of background tracking approaches (necessary for adjusting models based on changes to the code over time). Finally, updates iOS project configuration.

Background tracking and offline syncing improvements:

  • Added a comprehensive historical and technical reference for background tracking and sampling strategies in docs/background_tracking_history.md, including requirements, implementation status, and validation steps.
  • Refactored background tracking to ensure five random fixes per day are captured, masked, and persisted locally until acknowledged by the server, with automatic retries when connectivity is restored. The new logic is centralized in lib/utils/BackgroundTracking.dart and integrated via lib/main.dart and related files. [1] [2] [3] [4]
  • Updated callbackDispatcher and UI flows to invoke syncPendingFixes on startup and after tracking configuration, ensuring pending background location samples are reliably synced. [1] [2] [3]

Report syncing and offline queue integration:

  • Introduced ReportSyncService to support queuing and syncing of user reports when offline, replacing direct API calls in various controllers and pages. This ensures reports are not lost due to connectivity issues and are retried automatically. [1] [2] [3] [4] [5] [6] [7]

iOS project configuration and build settings:

  • Updated the iOS project (ios/Runner.xcodeproj/project.pbxproj and ios/Runner/Info.plist) to use the correct bundle identifier (cat.ibeji.tigatrapp), changed the development team to 6MGZ4KYJ2V, and added missing framework dependencies for new features. This aligns the build with production requirements and resolves provisioning/profile issues. [1] [2] [3] [4] [5] [6] [7] [8] [9]

These changes collectively improve reliability, maintainability, and production readiness for background tracking and report syncing.

…locally and syncing when connectivity is restored.

- Removed direct API calls from BiteReportController and BreedingSiteReportController.
- Integrated ReportSyncService for handling report submissions and queuing when offline.
- Added error handling and success dialogs for report submissions.
- Created new service (report_sync_service.dart) to manage pending reports and sync them with the server.
- Updated BackgroundTracking to include functionality for syncing pending fixes.
- Adjusted dependencies in pubspec.yaml and updated pubspec.lock for path_provider.
Copilot AI review requested due to automatic review settings December 11, 2025 17:02
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request refactors the mosquito tracking app's report submission and background location tracking to support offline persistence with automatic retry. It introduces client-side coordinate masking for background location fixes, adds a comprehensive report queue service, and updates the iOS project configuration for production deployment.

Key changes include:

  • Implementing offline-first architecture with local persistence for both user reports and background location fixes
  • Adding device-side coordinate masking (0.025° grid) for background tracking to ensure privacy even before server sync
  • Refactoring report controllers to use the new ReportSyncService instead of direct API calls

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
pubspec.yaml Adds path_provider as a direct dependency for accessing application directories to store queued data
pubspec.lock Updates path_provider dependency type from transitive to direct main
lib/utils/BackgroundTracking.dart Implements offline persistence for background location fixes with coordinate masking, queue management, and connectivity-aware retry logic
lib/services/report_sync_service.dart Introduces new service to queue and sync user reports (adult, bite, breeding) when offline with automatic retry
lib/pages/reports/sites/breeding_site_report_controller.dart Refactors to use ReportSyncService instead of direct API calls, adds queued submission handling
lib/pages/reports/bite/bite_report_controller.dart Refactors to use ReportSyncService instead of direct API calls, adds queued submission handling
lib/pages/reports/adult/adult_report_controller.dart Refactors to use ReportSyncService instead of direct API calls, adds queued submission handling and campaign display logic
lib/pages/main/home_page.dart Triggers pending report sync when home page loads to flush any queued reports
lib/pages/main/drawer_and_header.dart Adds syncPendingFixes call when configuring background tracking
lib/main.dart Initializes ReportSyncService at app startup and adds syncPendingFixes handler to callback dispatcher
ios/Runner/Info.plist Reformats indentation and changes CFBundleIdentifier to use build variable substitution
ios/Runner.xcodeproj/project.pbxproj Updates development team, bundle identifier to production value (cat.ibeji.tigatrapp), and adds framework dependencies
docs/background_tracking_history.md Adds comprehensive documentation of background tracking evolution, sampling strategies, and implementation details

Comment on lines +466 to +468
debugPrint(
'Missing queued adult attachments, dropping report ${report.id}');
return true;
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When photos are missing for a queued adult report, the method returns true to indicate success and drops the report. This means reports with missing attachments are silently discarded without notifying the user. Users who submitted these reports while offline will believe they were successfully synced when they actually weren't. Consider notifying the user or logging this as a critical error rather than silently dropping the data.

Suggested change
debugPrint(
'Missing queued adult attachments, dropping report ${report.id}');
return true;
FlutterError.reportError(FlutterErrorDetails(
exception: Exception('Missing queued adult attachments for report ${report.id}'),
library: 'report_sync_service',
context: ErrorDescription('Queued adult report is missing required photo attachments and cannot be synced.'),
));
// Do not drop the report; return false to indicate failure so it remains in the queue
return false;

Copilot uses AI. Check for mistakes.
Comment on lines +561 to +563
debugPrint(
'Missing queued breeding attachments, dropping report ${report.id}');
return true;
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When photos are missing for a queued breeding site report, the method returns true to indicate success and drops the report. This means reports with missing attachments are silently discarded without notifying the user. Users who submitted these reports while offline will believe they were successfully synced when they actually weren't. Consider notifying the user or logging this as a critical error rather than silently dropping the data.

Suggested change
debugPrint(
'Missing queued breeding attachments, dropping report ${report.id}');
return true;
FlutterError.reportError(FlutterErrorDetails(
exception: Exception('Missing queued breeding attachments for report ${report.id}'),
library: 'report_sync_service',
context: ErrorDescription('Breeding site report sync'),
));
// TODO: Notify user about failed sync due to missing attachments.
return false;

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +224
for (final entry in stored) {
try {
final map = jsonDecode(entry) as Map<String, dynamic>;
final pending = PendingReport.fromJson(map);
if (_normalizeQueuedReport(pending)) {
queueMutated = true;
}
_queue.add(pending);
} catch (e) {
debugPrint('Failed to decode pending report: $e');
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block catches all exceptions but only logs them with debugPrint. When decoding fails, the individual entry is skipped but there's no mechanism to notify anyone that the persisted queue is corrupted or partially unreadable. Consider adding telemetry or more visible logging for production environments to detect data corruption issues.

Copilot uses AI. Check for mistakes.
Comment on lines +76 to +79
final parsedCreatedAt =
DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now();
final createdAt =
parsedCreatedAt.isUtc ? parsedCreatedAt : parsedCreatedAt.toUtc();
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fromJson method falls back to DateTime.now() when the createdAt timestamp cannot be parsed. This could cause reports to be timestamped with the wrong date/time, misrepresenting when they were actually created. For scientific data collection, accurate timestamps are critical. Consider rejecting the report entirely if the timestamp is unparseable, or at minimum log this as a critical error.

Suggested change
final parsedCreatedAt =
DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now();
final createdAt =
parsedCreatedAt.isUtc ? parsedCreatedAt : parsedCreatedAt.toUtc();
final createdAtStr = json['createdAt'] as String?;
final parsedCreatedAt = createdAtStr != null ? DateTime.tryParse(createdAtStr) : null;
if (parsedCreatedAt == null) {
throw FormatException('Invalid createdAt timestamp: $createdAtStr');
}
final createdAt = parsedCreatedAt.isUtc ? parsedCreatedAt : parsedCreatedAt.toUtc();

Copilot uses AI. Check for mistakes.
Comment on lines +520 to +534
if (environment != null) {
try {
eventEnvironment = BiteRequestEventEnvironmentEnum.valueOf(environment);
} catch (_) {
eventEnvironment = null;
}
}

if (moment != null) {
try {
eventMoment = BiteRequestEventMomentEnum.valueOf(moment);
} catch (_) {
eventMoment = null;
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch blocks silently ignore exceptions when parsing enum values, defaulting to null. While this provides resilience against invalid data, it means reports with invalid environment or moment values will be submitted with null fields without any indication that data was lost. Consider logging these parsing failures to help detect data quality issues.

Copilot uses AI. Check for mistakes.
Comment on lines +445 to +451
factory _PendingFix.fromJson(Map<String, dynamic> json) {
return _PendingFix(
latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(),
power: (json['power'] as num).toDouble(),
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.now().toUtc(),
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback to DateTime.now().toUtc() when parsing fails could introduce data integrity issues. If createdAt cannot be parsed, using the current time instead of the original creation time will misrepresent when the fix was actually captured. This could affect analysis of location tracking patterns. Consider either discarding the fix if the timestamp is unparseable, or logging a warning about the timestamp issue.

Suggested change
factory _PendingFix.fromJson(Map<String, dynamic> json) {
return _PendingFix(
latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(),
power: (json['power'] as num).toDouble(),
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.now().toUtc(),
static _PendingFix? fromJson(Map<String, dynamic> json) {
final createdAtStr = json['createdAt'] as String?;
final createdAt = DateTime.tryParse(createdAtStr ?? '');
if (createdAt == null) {
debugPrint('Warning: Unparseable createdAt timestamp in _PendingFix: "$createdAtStr". Discarding fix.');
return null;
}
return _PendingFix(
latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(),
power: (json['power'] as num).toDouble(),
createdAt: createdAt,

Copilot uses AI. Check for mistakes.
Comment on lines +314 to +341
_normalizeQueuedReport(report);
_queue.add(report);
await _persistQueue();
_ensureRetryTimer();
notifyListeners();
unawaited(syncPendingReports());
}

bool _normalizeQueuedReport(PendingReport report) {
bool mutated = false;

final createdAtRaw = report.payload['createdAt'];
if (createdAtRaw is String) {
final parsed = DateTime.tryParse(createdAtRaw);
if (parsed != null) {
final normalized = parsed.isUtc ? parsed : parsed.toUtc();
final normalizedString = normalized.toIso8601String();
if (normalizedString != createdAtRaw) {
report.payload['createdAt'] = normalizedString;
mutated = true;
}
}
} else if (createdAtRaw == null) {
report.payload['createdAt'] = report.createdAt.toIso8601String();
mutated = true;
}

return mutated;
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method directly mutates the payload map of the PendingReport object and returns a boolean to indicate whether it was changed. This side-effect based approach makes it difficult to reason about when and how reports are modified. Consider making PendingReport immutable or explicitly documenting that this method mutates its parameter, as this could lead to unexpected behavior if the same report is processed multiple times.

Suggested change
_normalizeQueuedReport(report);
_queue.add(report);
await _persistQueue();
_ensureRetryTimer();
notifyListeners();
unawaited(syncPendingReports());
}
bool _normalizeQueuedReport(PendingReport report) {
bool mutated = false;
final createdAtRaw = report.payload['createdAt'];
if (createdAtRaw is String) {
final parsed = DateTime.tryParse(createdAtRaw);
if (parsed != null) {
final normalized = parsed.isUtc ? parsed : parsed.toUtc();
final normalizedString = normalized.toIso8601String();
if (normalizedString != createdAtRaw) {
report.payload['createdAt'] = normalizedString;
mutated = true;
}
}
} else if (createdAtRaw == null) {
report.payload['createdAt'] = report.createdAt.toIso8601String();
mutated = true;
}
return mutated;
final normalizedReport = _normalizeQueuedReport(report);
_queue.add(normalizedReport);
await _persistQueue();
_ensureRetryTimer();
notifyListeners();
unawaited(syncPendingReports());
}
PendingReport _normalizeQueuedReport(PendingReport report) {
final payload = Map<String, dynamic>.from(report.payload);
final createdAtRaw = payload['createdAt'];
if (createdAtRaw is String) {
final parsed = DateTime.tryParse(createdAtRaw);
if (parsed != null) {
final normalized = parsed.isUtc ? parsed : parsed.toUtc();
final normalizedString = normalized.toIso8601String();
if (normalizedString != createdAtRaw) {
payload['createdAt'] = normalizedString;
}
}
} else if (createdAtRaw == null) {
payload['createdAt'] = report.createdAt.toIso8601String();
}
return PendingReport(
id: report.id,
type: report.type,
payload: payload,
attachments: report.attachments,
createdAt: report.createdAt,
);

Copilot uses AI. Check for mistakes.
Comment on lines 121 to 140
try {
await _logAnalyticsEvent('submit_report');

// Step 1: Create location request
final locationRequest = LocationRequest((b) => b
..source_ = _reportData.locationSource
..point.latitude = _reportData.latitude!
..point.longitude = _reportData.longitude!);
final result =
await _reportSyncService.submitAdultReport(_reportData.copy());

// Step 3: Process photos
final List<MultipartFile> photos = [];
final uuid = Uuid();
for (final photo in _reportData.photos) {
photos.add(await MultipartFile.fromBytes(photo,
filename:
'${uuid.v4()}.jpg', // NOTE: Filename is required by the API
contentType: DioMediaType('image', 'jpeg')));
}
final photosRequest = BuiltList<MultipartFile>(photos);

// Step 4: Prepare notes
final notes =
_reportData.notes?.isNotEmpty == true ? _reportData.notes! : '';

// Steo 5: Tags
final userTags = await UserManager.getHashtags();
final tags = userTags != null ? BuiltList<String>(userTags) : null;

// Step 6: Make API call
final response = await _observationsApi.create(
createdAt: _reportData.createdAt.toUtc(),
sentAt: DateTime.now().toUtc(),
location: locationRequest,
photos: photosRequest,
note: notes,
eventEnvironment: _reportData.environmentAnswer?.name ?? '',
eventMoment: _reportData.eventMoment?.name ?? null,
tags: tags,
);
if (!mounted) return;

if (response.statusCode == 201) {
ReportDialogs.showSuccessDialog(
context,
onOkPressed: () async {
Navigator.pop(context); // close the success dialog
Country? country = response.data?.location.country;
if (country == null) {
Navigator.of(context).popUntil((route) => route.isFirst);
return;
}

try {
final campaignsResponse = await _campaignsApi.list(
countryId: country.id,
isActive: true,
pageSize: 1,
orderBy: ['-start_date'].build(),
);
final Campaign? campaign =
campaignsResponse.data?.results?.firstOrNull;
if (campaign != null) {
Dialogs.showAlertCampaign(
context,
campaign,
(context) =>
Navigator.of(context).popUntil((route) => route.isFirst),
);
} else {
Navigator.of(context).popUntil((route) => route.isFirst);
}
} catch (e) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
);
if (result.status == ReportSubmissionStatus.sent) {
await _handleSuccessfulSubmission(result.data);
} else {
ReportDialogs.showErrorDialog(
context, 'Server error: ${response.statusCode}');
await _handleQueuedSubmission();
}
} catch (e) {
ReportDialogs.showErrorDialog(context, 'Failed to submit report: $e');
} finally {
setState(() {
_isSubmitting = false;
});
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try-finally block is missing a catch block. If submitAdultReport or the subsequent handlers throw an exception, it will propagate uncaught to the caller. This means errors won't be handled gracefully and users won't see any error dialog. Add a catch block that calls _handleQueuedSubmission() or shows an appropriate error message to the user.

Copilot uses AI. Check for mistakes.

- [x] Centralize scheduling, masking, queueing, and sync logic in `lib/utils/BackgroundTracking.dart`.
- [x] Persist pending fixes as JSON in SharedPreferences and attach a deterministic `trackingUUID` so the server can group coverage windows.
- [x] Guarantee five random executions per calendar day (removed the fractional-day throttle so we always schedule the remaining slots).
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checklist item states that the fractional-day throttle was removed so all remaining slots are scheduled, but the Implementation Notes section (line 48) and the Sampling Strategy Timeline (line 20) clearly indicate that fractional scaling was restored. This inconsistency could confuse readers about the actual implementation. Update this line to reflect that fractional scaling was restored to match legacy behavior.

Suggested change
- [x] Guarantee five random executions per calendar day (removed the fractional-day throttle so we always schedule the remaining slots).
- [x] Guarantee five random executions per calendar day (restored fractional-day scaling so the number of slots scheduled matches the remaining fraction of the day, as in legacy behavior).

Copilot uses AI. Check for mistakes.
Comment on lines +129 to +164
Future<void> syncPendingReports() async {
if (_isSyncing || _queue.isEmpty) {
return;
}

_isSyncing = true;
try {
while (_queue.isNotEmpty) {
final report = _queue.first;
bool success = false;
try {
success = await _sendPendingReport(report);
} catch (e, stack) {
debugPrint('Failed to sync pending report ${report.id}: $e');
debugPrintStack(stackTrace: stack);
success = false;
}
if (success) {
_queue.removeAt(0);
await _persistQueue();
await _deleteAttachments(report.attachments);
notifyListeners();
} else {
break;
}
}
} finally {
_isSyncing = false;
if (_queue.isNotEmpty) {
_ensureRetryTimer();
} else {
_retryTimer?.cancel();
_retryTimer = null;
}
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The syncPendingReports method implements critical offline queue logic but lacks test coverage. Given that other service components in this repository have comprehensive test coverage, this new sync logic should be tested to ensure it correctly handles scenarios like partial sync failures, queue persistence, and retry scheduling.

Copilot uses AI. Check for mistakes.
@epou
Copy link
Member

epou commented Dec 22, 2025

Closing in favor of #673

@epou epou closed this Dec 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants