Le foto venivano zippate tutte in un unico allegato: oltre i 100 MB i servizi rifiutavano l'invio (MaxUploadSizeExceededException) e, costruendo tutto in RAM sul thread della UI, il dispositivo si bloccava. - Invio diviso in più email da massimo ~40 MB ciascuna - Zip scritti su disco una parte alla volta e caricati in streaming - Preparazione in background, avanzamento "parte X di N" - Richiesta se includere le foto, con il loro peso (default: solo i log) - Database inviato insieme al file -wal - Avviso subito se si è offline Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
399 lines
13 KiB
C#
399 lines
13 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using SteUp.Data.LocalDb;
|
|
using SteUp.Shared.Core.Dto;
|
|
using SteUp.Shared.Core.Entities;
|
|
using SteUp.Shared.Core.Helpers;
|
|
using SteUp.Shared.Core.Interface.System;
|
|
|
|
namespace SteUp.Maui.Core.Services;
|
|
|
|
public class FileManager(
|
|
IDbPathProvider dbPathProvider,
|
|
ILogger<FileManager> logger) : IFileManager
|
|
{
|
|
private static string AttachedRoot =>
|
|
Path.Combine(FileSystem.CacheDirectory, "attached");
|
|
|
|
private async Task<AttachedDto> ConvertToDto(FileInfo file, AttachedDto.TypeAttached type, bool isFromToUpload)
|
|
{
|
|
var (origUrl, thumbUrl) = await SaveAndCreateThumbAsync(
|
|
await File.ReadAllBytesAsync(file.FullName),
|
|
file.Name
|
|
);
|
|
|
|
return new AttachedDto
|
|
{
|
|
Name = file.Name,
|
|
Path = file.FullName,
|
|
TempPath = origUrl,
|
|
ThumbPath = thumbUrl,
|
|
Type = type,
|
|
SavedOnAppData = true,
|
|
ToUpload = isFromToUpload
|
|
};
|
|
}
|
|
|
|
private const string ToUploadFolderName = "toUpload";
|
|
|
|
private string GetInspectionBaseDir(Ispezione ispezione)
|
|
{
|
|
var baseDir = FileSystem.AppDataDirectory;
|
|
return Path.Combine(baseDir, "attached", $"inspection_{GetInspectionKey(ispezione)}");
|
|
}
|
|
|
|
private string GetInspectionToUploadDir(Ispezione ispezione)
|
|
=> Path.Combine(GetInspectionBaseDir(ispezione), ToUploadFolderName);
|
|
|
|
public string GetFileToUploadDir(Ispezione ispezione, string fileName) =>
|
|
Path.Combine(GetInspectionToUploadDir(ispezione), fileName);
|
|
|
|
private string GetInspectionFinalDir(Ispezione ispezione)
|
|
=> GetInspectionBaseDir(ispezione);
|
|
|
|
/// <summary>
|
|
/// Ritorna i file dell'ispezione filtrati per nome.
|
|
/// Per default include sia "final" sia "toUpload" (utile per UI).
|
|
/// </summary>
|
|
public async Task<List<AttachedDto>?> GetInspectionFiles(
|
|
Ispezione ispezione,
|
|
List<string> fileNameFilter,
|
|
bool includeToUpload,
|
|
CancellationToken ct)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ispezione);
|
|
ArgumentNullException.ThrowIfNull(fileNameFilter);
|
|
|
|
var baseDir = GetInspectionBaseDir(ispezione);
|
|
if (!Directory.Exists(baseDir)) return null;
|
|
|
|
var result = new List<AttachedDto>();
|
|
|
|
var finalDir = GetInspectionFinalDir(ispezione);
|
|
if (Directory.Exists(finalDir))
|
|
{
|
|
var finalFiles = new DirectoryInfo(finalDir)
|
|
.GetFiles("*", SearchOption.TopDirectoryOnly);
|
|
|
|
foreach (var file in finalFiles)
|
|
{
|
|
if (file.Directory?.Name == ToUploadFolderName)
|
|
continue;
|
|
|
|
if (!fileNameFilter.Contains(file.FullName))
|
|
continue;
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
result.Add(await ConvertToDto(
|
|
file,
|
|
AttachedDto.TypeAttached.Image,
|
|
isFromToUpload: false));
|
|
}
|
|
}
|
|
|
|
if (!includeToUpload) return result;
|
|
|
|
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
|
if (!Directory.Exists(toUploadDir)) return result;
|
|
|
|
var toUploadFiles = new DirectoryInfo(toUploadDir)
|
|
.GetFiles("*", SearchOption.TopDirectoryOnly);
|
|
|
|
foreach (var file in toUploadFiles)
|
|
{
|
|
if (!fileNameFilter.Contains(file.Name))
|
|
continue;
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
result.Add(await ConvertToDto(
|
|
file,
|
|
AttachedDto.TypeAttached.Image,
|
|
isFromToUpload: true));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Salva SEMPRE in /toUpload.
|
|
/// </summary>
|
|
public async Task<string?> SaveInspectionFile(
|
|
Ispezione ispezione,
|
|
byte[] file,
|
|
string fileName,
|
|
CancellationToken ct)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ispezione);
|
|
ArgumentNullException.ThrowIfNull(file);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
|
|
|
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
|
Directory.CreateDirectory(toUploadDir);
|
|
|
|
var filePath = Path.Combine(toUploadDir, fileName);
|
|
await File.WriteAllBytesAsync(filePath, file, ct);
|
|
|
|
return filePath;
|
|
}
|
|
|
|
public Task<string?> MoveInspectionFileFromToUploadToFinal(
|
|
Ispezione ispezione,
|
|
string fileName,
|
|
bool overwrite,
|
|
CancellationToken ct)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ispezione);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
|
var finalDir = GetInspectionFinalDir(ispezione);
|
|
|
|
if (!Directory.Exists(toUploadDir)) return Task.FromResult<string?>(null);
|
|
|
|
var sourcePath = Path.Combine(toUploadDir, fileName);
|
|
if (!File.Exists(sourcePath)) return Task.FromResult<string?>(null);
|
|
|
|
Directory.CreateDirectory(finalDir);
|
|
|
|
var destPath = Path.Combine(finalDir, fileName);
|
|
|
|
if (File.Exists(destPath))
|
|
{
|
|
if (!overwrite) return Task.FromResult<string?>(null);
|
|
File.Delete(destPath);
|
|
}
|
|
|
|
File.Move(sourcePath, destPath);
|
|
|
|
// Pulizia: se /toUpload resta vuota la elimino
|
|
CleanupDirectoriesIfEmpty(ispezione);
|
|
|
|
return Task.FromResult<string?>(destPath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rimuove un file cercandolo prima in /toUpload e poi in final (o viceversa).
|
|
/// Default: prova a cancellare ovunque.
|
|
/// </summary>
|
|
public bool RemoveInspectionFile(
|
|
Ispezione ispezione,
|
|
string fileName,
|
|
bool removeAlsoFromFinal,
|
|
bool removeAlsoFromToUpload)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ispezione);
|
|
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
|
|
|
var removed = false;
|
|
|
|
if (removeAlsoFromToUpload)
|
|
{
|
|
var toUploadPath = Path.Combine(GetInspectionToUploadDir(ispezione), fileName);
|
|
if (File.Exists(toUploadPath))
|
|
{
|
|
File.Delete(toUploadPath);
|
|
removed = true;
|
|
}
|
|
}
|
|
|
|
if (removeAlsoFromFinal)
|
|
{
|
|
var finalPath = Path.Combine(GetInspectionFinalDir(ispezione), fileName);
|
|
if (File.Exists(finalPath))
|
|
{
|
|
File.Delete(finalPath);
|
|
removed = true;
|
|
}
|
|
}
|
|
|
|
if (removed)
|
|
CleanupDirectoriesIfEmpty(ispezione);
|
|
|
|
return removed;
|
|
}
|
|
|
|
public bool RemoveInspection(Ispezione ispezione, bool removeAlsoFromFinal, bool removeAlsoFromToUpload)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ispezione);
|
|
|
|
var removedAnything = false;
|
|
|
|
if (removeAlsoFromToUpload)
|
|
TryDeleteDirectory(GetInspectionToUploadDir(ispezione));
|
|
|
|
if (removeAlsoFromFinal)
|
|
TryDeleteDirectory(GetInspectionFinalDir(ispezione));
|
|
|
|
try
|
|
{
|
|
CleanupDirectoriesIfEmpty(ispezione);
|
|
}
|
|
catch(Exception e)
|
|
{
|
|
logger.LogError(e, e.Message);
|
|
SentrySdk.CaptureException(e);
|
|
}
|
|
|
|
return removedAnything;
|
|
|
|
void TryDeleteDirectory(string? dir)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(dir)) return;
|
|
|
|
try
|
|
{
|
|
if (!Directory.Exists(dir)) return;
|
|
|
|
Directory.Delete(dir, recursive: true);
|
|
removedAnything = true;
|
|
}
|
|
catch(Exception e)
|
|
{
|
|
logger.LogError(e, e.Message);
|
|
SentrySdk.CaptureException(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void CleanupDirectoriesIfEmpty(Ispezione ispezione)
|
|
{
|
|
var baseDir = GetInspectionBaseDir(ispezione);
|
|
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
|
|
|
// 1) se /toUpload esiste e vuota => delete
|
|
if (Directory.Exists(toUploadDir) && !Directory.EnumerateFileSystemEntries(toUploadDir).Any())
|
|
Directory.Delete(toUploadDir);
|
|
|
|
// 2) se base dir vuota (attenzione: dopo delete toUpload) => delete
|
|
if (Directory.Exists(baseDir) && !Directory.EnumerateFileSystemEntries(baseDir).Any())
|
|
Directory.Delete(baseDir);
|
|
}
|
|
|
|
public async Task<string> SaveToTempStorage(Stream file, string fileName, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(file);
|
|
|
|
if (file.CanSeek)
|
|
file.Position = 0;
|
|
|
|
fileName = Path.GetFileName(fileName);
|
|
|
|
var dir = FileSystem.CacheDirectory;
|
|
var filePath = Path.Combine(dir, fileName);
|
|
|
|
await using var fileStream = File.Create(filePath);
|
|
await file.CopyToAsync(fileStream, ct);
|
|
|
|
return filePath;
|
|
}
|
|
|
|
public Task CleanTempStorageAsync(CancellationToken ct = default)
|
|
{
|
|
return Task.Run(() =>
|
|
{
|
|
if (Directory.Exists(AttachedRoot))
|
|
Directory.Delete(AttachedRoot, true);
|
|
}, ct);
|
|
}
|
|
|
|
public Task OpenFile(string fileName, string filePath)
|
|
{
|
|
#if IOS
|
|
throw new NotImplementedException();
|
|
#else
|
|
return Launcher.OpenAsync(new OpenFileRequest
|
|
{
|
|
Title = "Apri file",
|
|
File = new ReadOnlyFile(filePath)
|
|
});
|
|
#endif
|
|
}
|
|
|
|
public async Task<(string originalUrl, string thumbUrl)> SaveAndCreateThumbAsync(
|
|
byte[] bytes, string fileName, CancellationToken ct = default)
|
|
{
|
|
Directory.CreateDirectory(AttachedRoot);
|
|
|
|
var id = Guid.NewGuid().ToString("N");
|
|
var safeName = SanitizeFileName(fileName);
|
|
|
|
var originalFile = $"{id}_{safeName}";
|
|
var thumbFile = $"{id}_thumb.jpg";
|
|
|
|
var originalPath = Path.Combine(AttachedRoot, originalFile);
|
|
await File.WriteAllBytesAsync(originalPath, bytes, ct);
|
|
|
|
var thumbPath = Path.Combine(AttachedRoot, thumbFile);
|
|
await ImageThumb.CreateThumbnailAsync(originalPath, thumbPath, maxSide: 320, quality: 70, ct);
|
|
|
|
return ($"https://localfiles/attached/{originalFile}",
|
|
$"https://localfiles/attached/{thumbFile}");
|
|
}
|
|
|
|
private static string SanitizeFileName(string fileName)
|
|
{
|
|
var name = Path.GetFileName(fileName);
|
|
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
|
}
|
|
|
|
private static string GetInspectionKey(Ispezione ispezione) =>
|
|
$"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}";
|
|
|
|
public Task<long> GetLogExportImagesSizeAsync(CancellationToken ct = default) =>
|
|
Task.Run(() => RetrieveAttachedImages().Sum(x => x.File.Length), ct);
|
|
|
|
public Task<ILogExport> PrepareLogExportAsync(bool includeImages, CancellationToken ct = default)
|
|
{
|
|
return Task.Run<ILogExport>(() =>
|
|
{
|
|
var workDir = Path.Combine(FileSystem.CacheDirectory, "log_export");
|
|
|
|
// Residui di un invio interrotto (app chiusa a metà): si riparte da zero.
|
|
if (Directory.Exists(workDir))
|
|
Directory.Delete(workDir, true);
|
|
Directory.CreateDirectory(workDir);
|
|
|
|
var logFiles = RetrieveLogFiles()
|
|
.Select(f => new LogExport.ZipItem(f, f.Name))
|
|
.ToList();
|
|
|
|
// Col journal WAL le modifiche recenti stanno ancora nel file -wal: senza, il database
|
|
// inviato può non avere le ultime ispezioni.
|
|
var dbPath = dbPathProvider.GetDbPath();
|
|
var dbFiles = new[] { dbPath, $"{dbPath}-wal" }
|
|
.Select(p => new FileInfo(p))
|
|
.Where(f => f.Exists)
|
|
.Select(f => new LogExport.ZipItem(f, f.Name))
|
|
.ToList();
|
|
|
|
var images = includeImages ? RetrieveAttachedImages() : [];
|
|
|
|
return new LogExport(workDir, logFiles, dbFiles, images, logger);
|
|
}, ct);
|
|
}
|
|
|
|
private static List<FileInfo> RetrieveLogFiles()
|
|
{
|
|
var directory = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "logs"));
|
|
return directory.Exists ? directory.GetFiles().ToList() : [];
|
|
}
|
|
|
|
private static List<LogExport.ZipItem> RetrieveAttachedImages()
|
|
{
|
|
var root = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached"));
|
|
if (!root.Exists) return [];
|
|
|
|
// Ordine per percorso: le foto della stessa ispezione finiscono nella stessa parte.
|
|
// Il percorso relativo mantiene le cartelle dentro lo zip (separatore "/" per compatibilità).
|
|
return root
|
|
.EnumerateFiles("*", SearchOption.AllDirectories)
|
|
.OrderBy(f => f.FullName, StringComparer.Ordinal)
|
|
.Select(f => new LogExport.ZipItem(f,
|
|
Path.GetRelativePath(root.FullName, f.FullName).Replace('\\', '/')))
|
|
.ToList();
|
|
}
|
|
}
|