Aves_mio0.88/lib/model/activity/activity_scan_service.dart
2026-10-03 12:51:15 +02:00

647 lines
19 KiB
Dart

// 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<ActivityScanResult> scanSaf(String treeUri) async {
final result = ActivityScanResult();
final seenUris = <String>{};
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<ActivityScanResult> scan() async {
final result = ActivityScanResult();
final seenUris = <String>{};
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<void> _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<void> _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<ActivityPreviewPoint> _buildPreviewPoints({
required int activityId,
required Set<List<LatLng>> tracks,
}) {
final result = <ActivityPreviewPoint>[];
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<LatLng> _simplifyForDbPreview(List<LatLng> points) {
const maxPreviewPoints = 300;
if (points.length <= maxPreviewPoints) {
return points;
}
final step = (points.length / maxPreviewPoints).ceil();
final result = <LatLng>[];
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<String> errors = [];
bool get hasErrors => errors.isNotEmpty;
@override
String toString() {
return 'ActivityScanResult('
'scannedFiles=$scannedFiles, '
'inserted=$inserted, '
'updated=$updated, '
'skippedUnchanged=$skippedUnchanged, '
'errors=${errors.length}'
')';
}
}