// lib/model/activity/activity_scan_service.dart import 'dart:io'; import 'package:aves/model/activity/activity_entry.dart'; import 'package:aves/model/activity/activity_folder_stats.dart'; import 'package:aves/model/activity/activity_origins.dart'; import 'package:aves/model/activity/activity_preview_point.dart'; import 'package:aves/model/activity/activity_track.dart'; import 'package:aves/model/db/db.dart'; import 'package:aves/model/db/folder_content_type.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/activity_file_parser.dart'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:path/path.dart' as p; class ActivityScanService { final LocalMediaDb db; /// Scanner legacy via path diretto. /// /// Su Android moderno può non funzionare per cartelle custom sotto /// `/storage/emulated/0` a causa dello scoped storage. final Directory? rootDirectory; const ActivityScanService({ required this.db, this.rootDirectory, }); /// Scanner corretto per Android moderno: usa Storage Access Framework. /// /// [treeUri] è il valore restituito da: /// /// ```dart /// final treeUri = await storageService.pickActivityDirectory(); /// ``` Future scanSaf(String treeUri) async { final result = ActivityScanResult(); final seenUris = {}; if (treeUri.trim().isEmpty) { result.errors.add('Tree URI attività vuoto.'); return result; } debugPrint('[ActivityScan][SAF] scan treeUri=$treeUri'); final files = await storageService.listActivityFiles(treeUri); debugPrint('[ActivityScan][SAF] files=${files.length}'); for (final file in files) { final uri = file['uri'] as String?; final fileName = file['name'] as String?; final relativePath = file['relativePath'] as String?; final sizeBytes = _asInt(file['size']); final lastModifiedMillis = _asInt(file['lastModified']); if (uri == null || uri.isEmpty || fileName == null || fileName.isEmpty) { result.errors.add('File attività con dati mancanti: $file'); continue; } if (!ActivityFileParser.isSupportedFileName(fileName)) { continue; } result.scannedFiles++; seenUris.add(uri); try { final existing = await db.loadActivityByUri(uri); if (existing != null && existing.sizeBytes == sizeBytes && existing.lastModifiedMillis == lastModifiedMillis && existing.scanStatus == 'ok') { result.skippedUnchanged++; continue; } final bytes = await storageService.readActivityFile(uri); if (bytes.isEmpty) { await _saveScanErrorFromMetadata( uri: uri, fileName: fileName, relativePath: relativePath, sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, error: 'File vuoto o non leggibile.', existing: existing, ); result.errors.add('$fileName: file vuoto o non leggibile'); continue; } final activity = await ActivityFileParser.parse( Uint8List.fromList(bytes), fileName: fileName, ); if (activity == null || activity.isEmpty) { await _saveScanErrorFromMetadata( uri: uri, fileName: fileName, relativePath: relativePath, sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, error: 'Nessuna traccia GPS valida trovata nel file.', existing: existing, ); result.errors.add('$fileName: nessuna traccia GPS valida'); continue; } final now = DateTime.now().millisecondsSinceEpoch; final entry = _entryFromTrackMetadata( activity: activity, uri: uri, fileName: fileName, relativePath: relativePath, sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, existing: existing, now: now, ); final activityId = await db.upsertActivity(entry); final previewPoints = _buildPreviewPoints( activityId: activityId, tracks: activity.tracks, ); await db.saveActivityPreviewPoints( activityId, previewPoints, ); if (existing == null) { result.inserted++; } else { result.updated++; } debugPrint( '[ActivityScan][SAF] saved id=$activityId file=$fileName ' 'points=${activity.pointCount} preview=${previewPoints.length}', ); } catch (e, st) { debugPrint('[ActivityScan][SAF] failed file=$uri: $e'); debugPrint('[ActivityScan][SAF] stack=$st'); result.errors.add('$fileName: $e'); try { final existing = await db.loadActivityByUri(uri); await _saveScanErrorFromMetadata( uri: uri, fileName: fileName, relativePath: relativePath, sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, error: '$e', existing: existing, ); } catch (_) { // Non bloccare lo scan se anche il salvataggio errore fallisce. } } } await db.markActivitiesMissingExcept(seenUris); await _refreshActivityFolders(); debugPrint( '[ActivityScan][SAF] done scanned=${result.scannedFiles} ' 'inserted=${result.inserted} updated=${result.updated} ' 'skipped=${result.skippedUnchanged} errors=${result.errors.length}', ); return result; } /// Scanner legacy via path diretto. /// /// Manteniamo questo metodo per debug o sistemi dove `dart:io` può leggere /// direttamente la cartella. Per Android moderno usare [scanSaf]. Future scan() async { final result = ActivityScanResult(); final seenUris = {}; final root = rootDirectory; if (root == null) { result.errors.add( 'Directory attività non configurata. Usa scanSaf(treeUri).', ); return result; } if (!await root.exists()) { result.errors.add( 'Directory attività non trovata: ${root.path}', ); return result; } debugPrint('[ActivityScan] scan root=${root.path}'); await for (final entity in root.list( recursive: true, followLinks: false, )) { if (entity is! File) continue; final fileName = p.basename(entity.path); if (!ActivityFileParser.isSupportedFileName(fileName)) { continue; } result.scannedFiles++; final uri = entity.path; seenUris.add(uri); try { final stat = await entity.stat(); final lastModifiedMillis = stat.modified.millisecondsSinceEpoch; final sizeBytes = stat.size; final existing = await db.loadActivityByUri(uri); if (existing != null && existing.sizeBytes == sizeBytes && existing.lastModifiedMillis == lastModifiedMillis && existing.scanStatus == 'ok') { result.skippedUnchanged++; continue; } final bytes = await entity.readAsBytes(); final activity = await ActivityFileParser.parse( Uint8List.fromList(bytes), fileName: fileName, ); if (activity == null || activity.isEmpty) { await _saveScanErrorFromMetadata( uri: uri, fileName: fileName, relativePath: _relativeParentPath(entity), sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, error: 'Nessuna traccia GPS valida trovata nel file.', existing: existing, ); result.errors.add('$fileName: nessuna traccia GPS valida'); continue; } final now = DateTime.now().millisecondsSinceEpoch; final entry = _entryFromTrackMetadata( activity: activity, uri: uri, fileName: fileName, relativePath: _relativeParentPath(entity), sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, existing: existing, now: now, ); final activityId = await db.upsertActivity(entry); final previewPoints = _buildPreviewPoints( activityId: activityId, tracks: activity.tracks, ); await db.saveActivityPreviewPoints( activityId, previewPoints, ); if (existing == null) { result.inserted++; } else { result.updated++; } debugPrint( '[ActivityScan] saved id=$activityId file=$fileName ' 'points=${activity.pointCount} preview=${previewPoints.length}', ); } catch (e, st) { debugPrint('[ActivityScan] failed file=${entity.path}: $e'); debugPrint('[ActivityScan] stack=$st'); result.errors.add('$fileName: $e'); try { final stat = await entity.stat(); final existing = await db.loadActivityByUri(uri); await _saveScanErrorFromMetadata( uri: uri, fileName: fileName, relativePath: _relativeParentPath(entity), sizeBytes: stat.size, lastModifiedMillis: stat.modified.millisecondsSinceEpoch, error: '$e', existing: existing, ); } catch (_) { // Non bloccare lo scan se anche il salvataggio errore fallisce. } } } await db.markActivitiesMissingExcept(seenUris); await _refreshActivityFolders(); debugPrint( '[ActivityScan] done scanned=${result.scannedFiles} ' 'inserted=${result.inserted} updated=${result.updated} ' 'skipped=${result.skippedUnchanged} errors=${result.errors.length}', ); return result; } Future _refreshActivityFolders() async { try { final activities = await db.loadActivities(); final folders = ActivityFolderStats.build(activities); await db.rawDb.delete( 'folders', where: 'content_type = ?', whereArgs: [FolderContentType.activity], ); if (folders.isNotEmpty) { await db.saveFoldersBatch(folders); } debugPrint( '[ActivityScan] refreshed activity folders=${folders.length}', ); } catch (e, st) { debugPrint('[ActivityScan] refresh activity folders failed: $e'); debugPrint('[ActivityScan] refresh activity folders stack=$st'); } } ActivityEntry _entryFromTrackMetadata({ required ActivityTrack activity, required String uri, required String fileName, required String? relativePath, required int? sizeBytes, required int? lastModifiedMillis, required ActivityEntry? existing, required int now, }) { final extension = p.extension(fileName).replaceFirst('.', '').toLowerCase(); final directoryName = relativePath == null || relativePath.isEmpty ? null : p.basename(relativePath); final durationMillis = activity.duration?.inMilliseconds; final startMillis = activity.startTime?.millisecondsSinceEpoch; final endMillis = startMillis != null && durationMillis != null ? startMillis + durationMillis : null; return ActivityEntry( id: existing?.id, origin: existing?.origin ?? ActivityOrigins.local, uri: uri, relativePath: relativePath, directoryName: directoryName, fileName: fileName, fileExtension: extension, format: activity.format, displayName: activity.displayName, routeName: activity.routeName, title: existing?.title ?? activity.routeName, description: null, notes: null, sport: activity.sport, activityType: activity.sport, deviceManufacturer: null, deviceModel: null, sourceApp: null, startTimeMillis: startMillis, endTimeMillis: endMillis, durationMillis: durationMillis, distanceMeters: activity.distanceMeters, elevationGainMeters: activity.elevationGainMeters, elevationLossMeters: null, minElevationMeters: null, maxElevationMeters: null, fileDistanceMeters: activity.fileDistanceMeters, fileElevationGainMeters: activity.fileElevationGainMeters, computedDistanceMeters: activity.computedDistanceMeters, computedElevationGainMeters: activity.computedElevationGainMeters, averageSpeedMps: _averageSpeedMps( activity.distanceMeters, activity.duration, ), maxSpeedMps: null, averagePaceSecPerKm: _averagePaceSecPerKm( activity.distanceMeters, activity.duration, ), averageHeartRate: null, maxHeartRate: null, averageCadence: null, maxCadence: null, calories: null, powerAvg: null, powerMax: null, pointCount: activity.pointCount, segmentCount: activity.tracks.length, lapCount: null, minLatitude: activity.minLatitude, maxLatitude: activity.maxLatitude, minLongitude: activity.minLongitude, maxLongitude: activity.maxLongitude, centerLatitude: activity.boundsCenter?.latitude ?? activity.center?.latitude, centerLongitude: activity.boundsCenter?.longitude ?? activity.center?.longitude, sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, dateAddedMillis: existing?.dateAddedMillis ?? now, dateModifiedMillis: now, lastScanMillis: now, isFavorite: existing?.isFavorite ?? false, isHidden: existing?.isHidden ?? false, rating: existing?.rating, tags: existing?.tags, scanStatus: 'ok', scanError: null, diagnostics: activity.diagnostics, ); } Future _saveScanErrorFromMetadata({ required String uri, required String fileName, required String? relativePath, required int? sizeBytes, required int? lastModifiedMillis, required String error, required ActivityEntry? existing, }) async { final now = DateTime.now().millisecondsSinceEpoch; final extension = p.extension(fileName).replaceFirst('.', '').toLowerCase(); final directoryName = relativePath == null || relativePath.isEmpty ? null : p.basename(relativePath); final entry = ActivityEntry( id: existing?.id, origin: existing?.origin ?? ActivityOrigins.local, uri: uri, relativePath: relativePath, directoryName: directoryName, fileName: fileName, fileExtension: extension, format: extension.toUpperCase(), displayName: fileName, routeName: null, title: null, description: null, notes: null, sport: null, activityType: null, deviceManufacturer: null, deviceModel: null, sourceApp: null, startTimeMillis: null, endTimeMillis: null, durationMillis: null, distanceMeters: null, elevationGainMeters: null, elevationLossMeters: null, minElevationMeters: null, maxElevationMeters: null, fileDistanceMeters: null, fileElevationGainMeters: null, computedDistanceMeters: null, computedElevationGainMeters: null, averageSpeedMps: null, maxSpeedMps: null, averagePaceSecPerKm: null, averageHeartRate: null, maxHeartRate: null, averageCadence: null, maxCadence: null, calories: null, powerAvg: null, powerMax: null, pointCount: null, segmentCount: null, lapCount: null, minLatitude: null, maxLatitude: null, minLongitude: null, maxLongitude: null, centerLatitude: null, centerLongitude: null, sizeBytes: sizeBytes, lastModifiedMillis: lastModifiedMillis, dateAddedMillis: existing?.dateAddedMillis ?? now, dateModifiedMillis: now, lastScanMillis: now, isFavorite: existing?.isFavorite ?? false, isHidden: existing?.isHidden ?? false, rating: existing?.rating, tags: existing?.tags, scanStatus: 'error', scanError: error, diagnostics: null, ); await db.upsertActivity(entry); } String? _relativeParentPath(File file) { final root = rootDirectory; final parentPath = file.parent.path; if (root == null) return parentPath; try { final relative = p.relative(parentPath, from: root.path); if (relative == '.' || relative.trim().isEmpty) return ''; return relative; } catch (_) { return parentPath; } } List _buildPreviewPoints({ required int activityId, required Set> tracks, }) { final result = []; var segmentIndex = 0; for (final segment in tracks) { final previewSegment = _simplifyForDbPreview(segment); for (int i = 0; i < previewSegment.length; i++) { final point = previewSegment[i]; if (!point.latitude.isFinite || !point.longitude.isFinite) continue; if (point.latitude.abs() > 90 || point.longitude.abs() > 180) continue; result.add( ActivityPreviewPoint( activityId: activityId, segmentIndex: segmentIndex, pointIndex: i, latitude: point.latitude, longitude: point.longitude, ), ); } segmentIndex++; } return result; } List _simplifyForDbPreview(List points) { const maxPreviewPoints = 300; if (points.length <= maxPreviewPoints) { return points; } final step = (points.length / maxPreviewPoints).ceil(); final result = []; for (int i = 0; i < points.length; i += step) { result.add(points[i]); } if (result.isEmpty || result.last != points.last) { result.add(points.last); } return result; } static int? _asInt(Object? value) { if (value == null) return null; if (value is int) return value; if (value is num) return value.toInt(); return int.tryParse('$value'); } static double? _averageSpeedMps(double? distanceMeters, Duration? duration) { if (distanceMeters == null || duration == null) return null; final seconds = duration.inSeconds; if (seconds <= 0) return null; return distanceMeters / seconds; } static double? _averagePaceSecPerKm( double? distanceMeters, Duration? duration, ) { if (distanceMeters == null || duration == null) return null; final km = distanceMeters / 1000; if (km <= 0) return null; return duration.inSeconds / km; } } class ActivityScanResult { int scannedFiles = 0; int inserted = 0; int updated = 0; int skippedUnchanged = 0; final List errors = []; bool get hasErrors => errors.isNotEmpty; @override String toString() { return 'ActivityScanResult(' 'scannedFiles=$scannedFiles, ' 'inserted=$inserted, ' 'updated=$updated, ' 'skippedUnchanged=$skippedUnchanged, ' 'errors=${errors.length}' ')'; } }