647 lines
19 KiB
Dart
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}'
|
|
')';
|
|
}
|
|
}
|