add upload mode to queue entries to prevent cross-submission

This commit is contained in:
stopflock
2025-08-28 13:51:44 -05:00
parent 2c275ec528
commit 9c05f1d7a9
5 changed files with 142 additions and 9 deletions

View File

@@ -180,7 +180,7 @@ class AppState extends ChangeNotifier {
void commitSession() {
final session = _sessionState.commitSession();
if (session != null) {
_uploadQueueState.addFromSession(session);
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
_startUploader();
}
}
@@ -188,7 +188,7 @@ class AppState extends ChangeNotifier {
void commitEditSession() {
final session = _sessionState.commitEditSession();
if (session != null) {
_uploadQueueState.addFromEditSession(session);
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
_startUploader();
}
}

View File

@@ -1,10 +1,12 @@
import 'package:latlong2/latlong.dart';
import 'camera_profile.dart';
import '../state/settings_state.dart';
class PendingUpload {
final LatLng coord;
final double direction;
final CameraProfile profile;
final UploadMode uploadMode; // Capture upload destination when queued
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
int attempts;
bool error;
@@ -13,6 +15,7 @@ class PendingUpload {
required this.coord,
required this.direction,
required this.profile,
required this.uploadMode,
this.originalNodeId,
this.attempts = 0,
this.error = false,
@@ -21,11 +24,24 @@ class PendingUpload {
// True if this is an edit of an existing camera, false if it's a new camera
bool get isEdit => originalNodeId != null;
// Get display name for the upload destination
String get uploadModeDisplayName {
switch (uploadMode) {
case UploadMode.production:
return 'Production';
case UploadMode.sandbox:
return 'Sandbox';
case UploadMode.simulate:
return 'Simulate';
}
}
Map<String, dynamic> toJson() => {
'lat': coord.latitude,
'lon': coord.longitude,
'dir': direction,
'profile': profile.toJson(),
'uploadMode': uploadMode.index,
'originalNodeId': originalNodeId,
'attempts': attempts,
'error': error,
@@ -37,6 +53,9 @@ class PendingUpload {
profile: j['profile'] is Map<String, dynamic>
? CameraProfile.fromJson(j['profile'])
: CameraProfile.genericAlpr(),
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries
originalNodeId: j['originalNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,

View File

@@ -1,10 +1,33 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../state/settings_state.dart';
class QueueSection extends StatelessWidget {
const QueueSection({super.key});
String _getUploadModeDisplayName(UploadMode mode) {
switch (mode) {
case UploadMode.production:
return 'Production';
case UploadMode.sandbox:
return 'Sandbox';
case UploadMode.simulate:
return 'Simulate';
}
}
Color _getUploadModeColor(UploadMode mode) {
switch (mode) {
case UploadMode.production:
return Colors.green; // Green for production (real)
case UploadMode.sandbox:
return Colors.orange; // Orange for sandbox (testing)
case UploadMode.simulate:
return Colors.grey; // Grey for simulate (fake)
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@@ -73,10 +96,13 @@ class QueueSection extends StatelessWidget {
return ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error ? Colors.red : null,
color: upload.error
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'),
subtitle: Text(
'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n'
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'

View File

@@ -25,11 +25,12 @@ class UploadQueueState extends ChangeNotifier {
}
// Add a completed session to the upload queue
void addFromSession(AddCameraSession session) {
void addFromSession(AddCameraSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: session.directionDegrees,
profile: session.profile,
uploadMode: uploadMode,
);
_queue.add(upload);
@@ -57,11 +58,12 @@ class UploadQueueState extends ChangeNotifier {
}
// Add a completed edit session to the upload queue
void addFromEditSession(EditCameraSession session) {
void addFromEditSession(EditCameraSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target,
direction: session.directionDegrees,
profile: session.profile,
uploadMode: uploadMode,
originalNodeId: session.originalNode.id, // Track which node we're editing
);
@@ -145,21 +147,24 @@ class UploadQueueState extends ChangeNotifier {
if (access == null) return; // not logged in
bool ok;
if (uploadMode == UploadMode.simulate) {
debugPrint('[UploadQueue] Processing item with uploadMode: ${item.uploadMode}');
if (item.uploadMode == UploadMode.simulate) {
// Simulate successful upload without calling real API
debugPrint('[UploadQueue] Simulating upload (no real API call)');
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
ok = true;
} else {
// Real upload -- pass uploadMode so uploader can switch between prod and sandbox
// Real upload -- use the upload mode that was saved when this item was queued
debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}');
final up = Uploader(access, () {
_queue.remove(item);
_saveQueue();
notifyListeners();
}, uploadMode: uploadMode);
}, uploadMode: item.uploadMode);
ok = await up.upload(item);
}
if (ok && uploadMode == UploadMode.simulate) {
if (ok && item.uploadMode == UploadMode.simulate) {
// Remove manually for simulate mode
_queue.remove(item);
_saveQueue();

View File

@@ -0,0 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:flock_map_app/models/pending_upload.dart';
import 'package:flock_map_app/models/camera_profile.dart';
import 'package:flock_map_app/state/settings_state.dart';
void main() {
group('PendingUpload', () {
test('should serialize and deserialize upload mode correctly', () {
// Test each upload mode
final testModes = [
UploadMode.production,
UploadMode.sandbox,
UploadMode.simulate,
];
for (final mode in testModes) {
final original = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: CameraProfile.flock(),
uploadMode: mode,
);
// Serialize to JSON
final json = original.toJson();
// Deserialize from JSON
final restored = PendingUpload.fromJson(json);
// Verify upload mode is preserved
expect(restored.uploadMode, equals(mode));
expect(restored.uploadModeDisplayName, equals(original.uploadModeDisplayName));
// Verify other fields too
expect(restored.coord.latitude, equals(original.coord.latitude));
expect(restored.coord.longitude, equals(original.coord.longitude));
expect(restored.direction, equals(original.direction));
expect(restored.profile.id, equals(original.profile.id));
}
});
test('should handle legacy JSON without uploadMode', () {
// Simulate old JSON format without uploadMode field
final legacyJson = {
'lat': 37.7749,
'lon': -122.4194,
'dir': 90.0,
'profile': CameraProfile.flock().toJson(),
'originalNodeId': null,
'attempts': 0,
'error': false,
// Note: no 'uploadMode' field
};
final upload = PendingUpload.fromJson(legacyJson);
// Should default to production mode for legacy entries
expect(upload.uploadMode, equals(UploadMode.production));
expect(upload.uploadModeDisplayName, equals('Production'));
});
test('should correctly identify edits vs new cameras', () {
final newCamera = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: CameraProfile.flock(),
uploadMode: UploadMode.production,
);
final editCamera = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: CameraProfile.flock(),
uploadMode: UploadMode.production,
originalNodeId: 12345,
);
expect(newCamera.isEdit, isFalse);
expect(editCamera.isEdit, isTrue);
});
});
}