Skip to content

Commit a66edc5

Browse files
authored
Merge pull request #735 from submersion-app/worktree-photo-attach-windows
fix(media): repair photo attachment on Windows and Linux
2 parents 911aa93 + 9de7ff5 commit a66edc5

16 files changed

Lines changed: 1112 additions & 90 deletions

lib/features/media/data/repositories/media_repository.dart

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,34 @@ class MediaRepository {
705705
}
706706
}
707707

708+
/// Get the set of local file paths already linked to a specific dive.
709+
///
710+
/// The desktop counterpart to [getLinkedAssetIdsForDive]: Windows / Linux
711+
/// imports are `localFile` rows with a null `platform_asset_id`, so the
712+
/// asset-id query cannot see them and duplicate detection has to key on the
713+
/// path instead. The path is also the more stable key -- the desktop
714+
/// picker's synthetic asset id embeds the file's mtime, so it changes
715+
/// whenever the file is touched.
716+
Future<Set<String>> getLinkedLocalPathsForDive(String diveId) async {
717+
try {
718+
final result = await _db
719+
.customSelect(
720+
'SELECT local_path FROM media '
721+
'WHERE dive_id = ? AND local_path IS NOT NULL',
722+
variables: [Variable.withString(diveId)],
723+
)
724+
.get();
725+
return result.map((row) => row.data['local_path'] as String).toSet();
726+
} catch (e, stackTrace) {
727+
_log.error(
728+
'Failed to get linked local paths for dive: $diveId',
729+
error: e,
730+
stackTrace: stackTrace,
731+
);
732+
rethrow;
733+
}
734+
}
735+
708736
/// Get GPS coordinates from media attached to a dive.
709737
///
710738
/// Returns a list of (latitude, longitude, takenAt) tuples from photos

lib/features/media/data/services/media_import_service.dart

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,31 @@ class MediaImportService {
112112
'Starting import of ${selectedAssets.length} assets for dive ${dive.id}',
113113
);
114114

115-
// Fetch already-linked asset IDs for this dive
116-
final existingAssetIds = await _mediaRepository.getLinkedAssetIdsForDive(
117-
dive.id,
118-
);
115+
// Two dedupe keys, one per origin. Gallery picks are matched on their
116+
// platform asset id; desktop picks are localFile rows with a null
117+
// platform_asset_id (see [_createMediaItemFromAsset]), invisible to that
118+
// query, so they are matched on the path instead.
119+
//
120+
// Each lookup only feeds one branch of the filter below, so query a
121+
// lookup only when the selection actually contains that kind of asset:
122+
// a mobile pick has no paths to compare and a desktop pick has no
123+
// gallery ids, and either way the unused set would be dead work.
124+
bool hasPath(AssetInfo a) => a.filePath != null && a.filePath!.isNotEmpty;
125+
final anyPaths = selectedAssets.any(hasPath);
126+
final anyGallery = selectedAssets.any((a) => !hasPath(a));
127+
128+
final existingAssetIds = anyGallery
129+
? await _mediaRepository.getLinkedAssetIdsForDive(dive.id)
130+
: const <String>{};
131+
final existingPaths = anyPaths
132+
? await _mediaRepository.getLinkedLocalPathsForDive(dive.id)
133+
: const <String>{};
119134

120135
// Filter out duplicates before processing
121-
final newAssets = selectedAssets
122-
.where((a) => !existingAssetIds.contains(a.id))
123-
.toList();
136+
final newAssets = selectedAssets.where((a) {
137+
if (hasPath(a)) return !existingPaths.contains(a.filePath);
138+
return !existingAssetIds.contains(a.id);
139+
}).toList();
124140
final skippedCount = selectedAssets.length - newAssets.length;
125141

126142
if (skippedCount > 0) {
@@ -174,12 +190,35 @@ class MediaImportService {
174190
MediaItem _createMediaItemFromAsset(AssetInfo asset, String diveId) {
175191
final now = DateTime.now();
176192

193+
// Windows / Linux have no platform photo library: the picker opens a file
194+
// dialog and the asset's id is a synthetic key into an in-memory map on
195+
// the picker service. Persisting such a row with the default
196+
// platformGallery sourceType left every path column blank and sent
197+
// display through PlatformGalleryResolver -> photo_manager, which has no
198+
// desktop-Windows backend, so the photo rendered "File not found" and the
199+
// pointer died with the process. When the picker hands us a real path,
200+
// store a localFile row instead: LocalFileResolver reads localPath
201+
// straight off disk on every desktop platform and survives a restart.
202+
final path = asset.filePath;
203+
final isLocalFile = path != null && path.isNotEmpty;
204+
177205
return MediaItem(
178206
id: '',
179207
diveId: diveId,
180-
platformAssetId: asset.id,
208+
// Deliberately null for a localFile row. The desktop picker's asset id
209+
// is a synthetic in-memory key, and several features gate purely on
210+
// `platformAssetId != null` -- MediaItem.isGalleryPhoto,
211+
// PhotoViewerPage's write-metadata action, resolvedFilePathProvider's
212+
// gallery fast path -- so carrying it would route a Windows file
213+
// through photo_manager, which has no backend there. Duplicate
214+
// detection for these rows keys on localPath instead.
215+
platformAssetId: isLocalFile ? null : asset.id,
181216
originalFilename: asset.filename,
182217
mediaType: asset.isVideo ? MediaType.video : MediaType.photo,
218+
sourceType: isLocalFile
219+
? MediaSourceType.localFile
220+
: MediaSourceType.platformGallery,
221+
localPath: isLocalFile ? path : null,
183222
latitude: asset.latitude,
184223
longitude: asset.longitude,
185224
takenAt: asset.createDateTime,

lib/features/media/data/services/photo_picker_service.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ class AssetInfo {
4949
/// Original filename if available.
5050
final String? filename;
5151

52+
/// Absolute filesystem path, when the asset came from a file dialog rather
53+
/// than a platform photo library.
54+
///
55+
/// Null on iOS / Android / macOS, where [id] is a real photo_manager asset
56+
/// ID and the library owns the file. Non-null on Windows / Linux, where
57+
/// there is no platform gallery and [id] is only a synthetic key into an
58+
/// in-memory map that dies with the process. Importers must persist this
59+
/// path so the row survives a restart -- see [AssetInfo] usage in
60+
/// `MediaImportService`.
61+
final String? filePath;
62+
5263
const AssetInfo({
5364
required this.id,
5465
required this.type,
@@ -59,6 +70,7 @@ class AssetInfo {
5970
this.latitude,
6071
this.longitude,
6172
this.filename,
73+
this.filePath,
6274
});
6375

6476
/// Whether this asset is a video.

lib/features/media/data/services/photo_picker_service_desktop.dart

Lines changed: 148 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
import 'dart:io';
22
import 'dart:typed_data';
33

4+
import 'package:flutter/foundation.dart' show compute;
5+
import 'package:image/image.dart' as img;
46
import 'package:image_picker/image_picker.dart';
7+
import 'package:path/path.dart' as p;
58

9+
import 'package:submersion/features/media/data/services/capture_time_reader.dart';
610
import 'package:submersion/features/media/data/services/photo_picker_service.dart';
711
import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart';
812

913
/// Photo picker implementation for Windows and Linux using image_picker.
1014
///
1115
/// This is a fallback implementation that doesn't support date-filtered
1216
/// gallery browsing. Users must manually browse and select files.
17+
///
18+
/// Windows and Linux have no platform photo library, so every [AssetInfo]
19+
/// this service produces carries its [AssetInfo.filePath]. That path is the
20+
/// only durable pointer to the file: [AssetInfo.id] is a synthetic key into
21+
/// [_filePathCache], which lives and dies with the process. Importers must
22+
/// persist the path (as a `localFile` row) rather than the id, or the photo
23+
/// resolves through photo_manager -- which has no Windows backend -- and
24+
/// renders "File not found".
1325
class PhotoPickerServiceDesktop implements PhotoPickerService {
1426
final ImagePicker _picker = ImagePicker();
1527

@@ -44,43 +56,44 @@ class PhotoPickerServiceDesktop implements PhotoPickerService {
4456
return [];
4557
}
4658

47-
final List<AssetInfo> results = [];
48-
49-
for (final file in files) {
50-
final path = file.path;
51-
final ioFile = File(path);
52-
53-
// Check if file exists
54-
if (!await ioFile.exists()) continue;
55-
56-
// Get file metadata
57-
final stat = await ioFile.stat();
58-
final modified = stat.modified;
59-
60-
// Generate a unique ID for this file
61-
final id = '${modified.millisecondsSinceEpoch}_${path.hashCode}';
62-
_filePathCache[id] = path;
63-
64-
// Determine if it's a video based on extension
65-
final extension = path.toLowerCase().split('.').last;
66-
final isVideo = ['mp4', 'mov', 'avi', 'mkv', 'webm'].contains(extension);
67-
68-
results.add(
69-
AssetInfo(
70-
id: id,
71-
type: isVideo ? AssetType.video : AssetType.image,
72-
createDateTime: modified,
73-
width: 0, // Not available without decoding
74-
height: 0,
75-
durationSeconds: null,
76-
latitude: null,
77-
longitude: null,
78-
filename: path.split(Platform.pathSeparator).last,
79-
),
80-
);
59+
// Reading capture time and dimensions means reading each file's bytes,
60+
// which would jank the UI thread for a pick of large photos (the old
61+
// stat()-only implementation was cheap enough to inline). Do the batch on
62+
// a background isolate, then register the paths here -- the isolate only
63+
// ever mutates its own copy of [_filePathCache].
64+
final assets = await compute(
65+
_extractAssets,
66+
files.map((f) => f.path).toList(),
67+
);
68+
for (final asset in assets) {
69+
final path = asset.filePath;
70+
if (path != null) _filePathCache[asset.id] = path;
8171
}
72+
return assets;
73+
}
8274

83-
return results;
75+
/// Builds the [AssetInfo] for one file chosen from the desktop file dialog
76+
/// and registers its path under the returned [AssetInfo.id].
77+
///
78+
/// Returns null when [ioFile] does not exist.
79+
///
80+
/// The capture time comes from the file's own container metadata (JPEG /
81+
/// HEIC EXIF `DateTimeOriginal`, or the MP4/MOV `mvhd`) via
82+
/// [readLocalCaptureTime], falling back to the mtime only when the file
83+
/// carries no capture time at all. Reporting the mtime unconditionally --
84+
/// as this service used to -- dates a photo to when it was copied off the
85+
/// camera card rather than when it was shot, which pushes it outside the
86+
/// dive window and leaves it unmatched.
87+
///
88+
/// [readLocalCaptureTime] returns wall-clock-UTC, but [AssetInfo] is
89+
/// contractually LOCAL (photo_manager's convention on mobile, which
90+
/// consumers such as `TripMediaScanner.toWallClockUtc` reinterpret). The
91+
/// components are therefore carried across verbatim into a local DateTime;
92+
/// returning the UTC value directly would double-convert it.
93+
AssetInfo? assetInfoForFile(File ioFile) {
94+
final asset = _assetInfoForPath(ioFile.path);
95+
if (asset != null) _filePathCache[asset.id] = asset.filePath!;
96+
return asset;
8497
}
8598

8699
@override
@@ -121,3 +134,103 @@ class PhotoPickerServiceDesktop implements PhotoPickerService {
121134
@override
122135
Future<MediaSourceMetadata?> getAssetMetadata(String assetId) async => null;
123136
}
137+
138+
/// Batch entry point for [compute]: top-level so it can cross the isolate
139+
/// boundary (an instance method would close over `this`). Paths that no
140+
/// longer resolve are dropped.
141+
List<AssetInfo> _extractAssets(List<String> paths) {
142+
final results = <AssetInfo>[];
143+
for (final path in paths) {
144+
final asset = _assetInfoForPath(path);
145+
// Null means the picked path vanished between dialog and read.
146+
if (asset != null) results.add(asset);
147+
}
148+
return results;
149+
}
150+
151+
/// Pure metadata read for one path. Top-level and cache-free so it is safe to
152+
/// run on a [compute] isolate; [PhotoPickerServiceDesktop.assetInfoForFile]
153+
/// wraps it to register the path on the main isolate.
154+
AssetInfo? _assetInfoForPath(String path) {
155+
final ioFile = File(path);
156+
if (!ioFile.existsSync()) return null;
157+
158+
final modified = ioFile.lastModifiedSync();
159+
final mime = _mimeFromExtension(p.extension(path).toLowerCase());
160+
161+
final capturedUtc = readLocalCaptureTime(ioFile, mime);
162+
final createDateTime = capturedUtc == null
163+
? modified
164+
: DateTime(
165+
capturedUtc.year,
166+
capturedUtc.month,
167+
capturedUtc.day,
168+
capturedUtc.hour,
169+
capturedUtc.minute,
170+
capturedUtc.second,
171+
capturedUtc.millisecond,
172+
// Today's readers are all second-granularity (EXIF
173+
// DateTimeOriginal, mvhd creation_time), so this is defensive --
174+
// but dropping sub-second components on a reinterpretation that
175+
// exists only to swap the isUtc flag would be a silent lossy step.
176+
capturedUtc.microsecond,
177+
);
178+
179+
final size = _dimensionsOf(ioFile, mime);
180+
181+
return AssetInfo(
182+
// Keyed on mtime + path so re-picking the same file in one session reuses
183+
// the entry. Only meaningful in-process; the durable pointer is the path.
184+
id: '${modified.millisecondsSinceEpoch}_${path.hashCode}',
185+
type: mime.startsWith('video/') ? AssetType.video : AssetType.image,
186+
createDateTime: createDateTime,
187+
// AssetResolutionService's timestamp+dimensions tiers reject a 0x0 row
188+
// outright, so real dimensions are what keep those tiers usable.
189+
width: size?.width ?? 0,
190+
height: size?.height ?? 0,
191+
durationSeconds: null,
192+
latitude: null,
193+
longitude: null,
194+
filename: p.basename(path),
195+
filePath: path,
196+
);
197+
}
198+
199+
/// Reads pixel dimensions from an image's header.
200+
///
201+
/// Only the header is parsed -- `startDecode` stops before any pixel decode --
202+
/// but the decoder API takes bytes, so the file is read in full first. That
203+
/// read is why the batch runs on a [compute] isolate; on the main thread a
204+
/// pick of large images would visibly jank the UI.
205+
///
206+
/// Returns null for videos and for anything the decoder cannot read.
207+
({int width, int height})? _dimensionsOf(File file, String mime) {
208+
if (!mime.startsWith('image/')) return null;
209+
try {
210+
final bytes = file.readAsBytesSync();
211+
// Extension first, then content sniffing for files whose name lies.
212+
final decoder =
213+
img.findDecoderForNamedImage(file.path) ??
214+
img.findDecoderForData(bytes);
215+
final info = decoder?.startDecode(bytes);
216+
if (info == null) return null;
217+
return (width: info.width, height: info.height);
218+
} on Object {
219+
// Unsupported or truncated container: dimensions are optional here.
220+
return null;
221+
}
222+
}
223+
224+
String _mimeFromExtension(String ext) => switch (ext) {
225+
'.jpg' || '.jpeg' => 'image/jpeg',
226+
'.png' => 'image/png',
227+
'.heic' => 'image/heic',
228+
'.heif' => 'image/heif',
229+
'.webp' => 'image/webp',
230+
'.gif' => 'image/gif',
231+
'.mp4' => 'video/mp4',
232+
'.mov' => 'video/quicktime',
233+
'.m4v' => 'video/x-m4v',
234+
'.avi' || '.mkv' || '.webm' => 'video/x-generic',
235+
_ => 'application/octet-stream',
236+
};

lib/features/media/presentation/helpers/photo_import_helper.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ class PhotoImportHelper {
6767
),
6868
buffer: Duration.zero,
6969
alreadyLinkedIds: alreadyLinkedIds,
70+
// Lets the Files tab link photos this dive's date window rejected.
71+
diveId: dive.id,
7072
);
7173
// coverage:ignore-end
7274

0 commit comments

Comments
 (0)