Invia i log a parti e chiedi se includere le foto
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>
This commit is contained in:
1 parent
a4e5709be6
commit
9799f6b0c7
9 files changed
+403
-183
No files matched your search
@@ -1,5 +1,4 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Data.LocalDb;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Entities;
|
||||
@@ -343,146 +342,57 @@ public class FileManager(
|
||||
private static string GetInspectionKey(Ispezione ispezione) =>
|
||||
$"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}";
|
||||
|
||||
public List<SendEmailDto.AttachmentsDto> GetFileForExport()
|
||||
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)
|
||||
{
|
||||
var attachments = new List<SendEmailDto.AttachmentsDto>();
|
||||
|
||||
// 1) log file singolo (se ti serve ancora)
|
||||
var logFile = RetrieveLogFile();
|
||||
if (!logFile.IsNullOrEmpty())
|
||||
return Task.Run<ILogExport>(() =>
|
||||
{
|
||||
attachments.Add(new SendEmailDto.AttachmentsDto
|
||||
{
|
||||
FileName = $"logs_{DateTime.Today:yyyyMMdd}.zip",
|
||||
FileContent = CreateZipBytes(logFile!)
|
||||
});
|
||||
}
|
||||
var workDir = Path.Combine(FileSystem.CacheDirectory, "log_export");
|
||||
|
||||
// 2) database zip
|
||||
var dbZip = CreateDatabaseZipAttachment();
|
||||
if (dbZip != null) attachments.Add(dbZip);
|
||||
|
||||
// 3) Img zip
|
||||
var attachedInfo = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached"));
|
||||
if (!attachedInfo.Exists) return attachments;
|
||||
|
||||
var attachedFiles = attachedInfo
|
||||
// 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();
|
||||
|
||||
if (attachedFiles.Count > 0)
|
||||
{
|
||||
attachments.Add(new SendEmailDto.AttachmentsDto
|
||||
{
|
||||
FileName = $"immagini_allegate_{DateTime.Today:yyyyMMdd}.zip",
|
||||
FileContent = CreateZipBytes(attachedInfo.FullName, attachedFiles)
|
||||
});
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private static List<FileInfo>? RetrieveLogFile()
|
||||
{
|
||||
var appDataPath = FileSystem.AppDataDirectory;
|
||||
var targetDirectory = Path.Combine(appDataPath, "logs");
|
||||
|
||||
var directory = new DirectoryInfo(targetDirectory);
|
||||
|
||||
List<FileInfo>? files = null;
|
||||
|
||||
if (directory.Exists)
|
||||
files = directory.GetFiles().ToList();
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private SendEmailDto.AttachmentsDto? CreateDatabaseZipAttachment()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
new FileInfo(dbPathProvider.GetDbPath())
|
||||
};
|
||||
|
||||
// Filtra solo quelli esistenti
|
||||
var existingFiles = files.Where(f => f.Exists).ToList();
|
||||
|
||||
if (existingFiles.Count == 0)
|
||||
return null;
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
|
||||
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
foreach (var file in existingFiles)
|
||||
{
|
||||
var entry = archive.CreateEntry(file.Name, CompressionLevel.Optimal);
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
fileStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
return new SendEmailDto.AttachmentsDto
|
||||
{
|
||||
FileName = $"database_{DateTime.Now:yyyyMMdd_HHmm}.zip",
|
||||
FileContent = memoryStream.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateZipBytes(IEnumerable<FileInfo> files)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Exists)
|
||||
continue;
|
||||
|
||||
// Nome dentro lo zip (evita path e collisioni minime)
|
||||
var entryName = file.Name;
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
fileStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreateZipBytes(string rootDir, IEnumerable<FileInfo> files)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Exists)
|
||||
continue;
|
||||
|
||||
// Path relativo rispetto a rootDir -> mantiene le directory nello zip
|
||||
var relativePath = Path.GetRelativePath(rootDir, file.FullName);
|
||||
|
||||
// Zip usa "/" come separatore: normalizziamo per compatibilità
|
||||
var entryName = relativePath.Replace('\\', '/');
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
fileStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Interface.System;
|
||||
|
||||
namespace SteUp.Maui.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Divide l'invio dei log in parti sotto <see cref="MaxPartBytes"/>: la prima porta log, database
|
||||
/// e le prime immagini, le successive solo immagini. Gli zip vengono scritti su disco (mai in RAM)
|
||||
/// una parte alla volta, così lo spazio occupato resta quello di una sola parte.
|
||||
/// </summary>
|
||||
internal sealed class LogExport : ILogExport
|
||||
{
|
||||
// Ben sotto il max-file-size dei servizi (100 MB) e i limiti tipici dei server di posta.
|
||||
// Una parte piccola regge anche l'upload da rete mobile entro il timeout HTTP del client.
|
||||
internal const long MaxPartBytes = 40L * 1024 * 1024;
|
||||
|
||||
private const int BufferSize = 81920;
|
||||
|
||||
internal readonly record struct ZipItem(FileInfo File, string EntryName);
|
||||
|
||||
private readonly string _workDir;
|
||||
private readonly List<ZipItem> _logFiles;
|
||||
private readonly List<ZipItem> _dbFiles;
|
||||
private readonly List<List<ZipItem>> _imageParts;
|
||||
private readonly ILogger _logger;
|
||||
private readonly List<string> _currentPartFiles = [];
|
||||
|
||||
public int PartCount => _imageParts.Count;
|
||||
|
||||
internal LogExport(string workDir, List<ZipItem> logFiles, List<ZipItem> dbFiles, List<ZipItem> images,
|
||||
ILogger logger)
|
||||
{
|
||||
_workDir = workDir;
|
||||
_logFiles = logFiles;
|
||||
_dbFiles = dbFiles;
|
||||
_logger = logger;
|
||||
_imageParts = SplitImages(images, logFiles.Concat(dbFiles).Sum(x => x.File.Length));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Riempie le parti in ordine (le foto di un'ispezione restano vicine). La prima parte parte
|
||||
/// già occupata da log e database: uso le dimensioni non compresse, una stima per eccesso.
|
||||
/// Un file più grande di una parte finisce da solo in una parte sua.
|
||||
/// </summary>
|
||||
private static List<List<ZipItem>> SplitImages(List<ZipItem> images, long firstPartReserved)
|
||||
{
|
||||
var parts = new List<List<ZipItem>> { new() };
|
||||
var currentSize = firstPartReserved;
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
var length = image.File.Length;
|
||||
if (currentSize > 0 && currentSize + length > MaxPartBytes)
|
||||
{
|
||||
parts.Add([]);
|
||||
currentSize = 0;
|
||||
}
|
||||
|
||||
parts[^1].Add(image);
|
||||
currentSize += length;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public Task<List<SendEmailDto.AttachmentsDto>> BuildPartAsync(int index, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, PartCount);
|
||||
|
||||
// Task.Run: la compressione non deve bloccare il thread della UI.
|
||||
return Task.Run(() =>
|
||||
{
|
||||
DeleteCurrentPartFiles();
|
||||
|
||||
var attachments = new List<SendEmailDto.AttachmentsDto>();
|
||||
var date = DateTime.Today.ToString("yyyyMMdd");
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
if (_logFiles.Count > 0)
|
||||
attachments.Add(WriteZip($"logs_{date}.zip", _logFiles, CompressionLevel.Optimal, ct));
|
||||
|
||||
if (_dbFiles.Count > 0)
|
||||
attachments.Add(WriteZip($"database_{DateTime.Now:yyyyMMdd_HHmm}.zip", _dbFiles,
|
||||
CompressionLevel.Optimal, ct));
|
||||
}
|
||||
|
||||
var images = _imageParts[index];
|
||||
if (images.Count > 0)
|
||||
{
|
||||
var suffix = PartCount > 1 ? $"_parte{index + 1}di{PartCount}" : string.Empty;
|
||||
|
||||
// JPEG già compressi: ricomprimerli costa CPU e non riduce quasi niente.
|
||||
attachments.Add(WriteZip($"immagini_allegate_{date}{suffix}.zip", images,
|
||||
CompressionLevel.NoCompression, ct));
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private SendEmailDto.AttachmentsDto WriteZip(string fileName, List<ZipItem> items, CompressionLevel level,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var path = Path.Combine(_workDir, fileName);
|
||||
_currentPartFiles.Add(path);
|
||||
|
||||
using (var zipStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize))
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create))
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
// FileShare.ReadWrite: log e database possono essere ancora aperti in scrittura.
|
||||
using var source = item.File.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
var entry = archive.CreateEntry(item.EntryName, level);
|
||||
entry.LastWriteTime = item.File.LastWriteTime;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
source.CopyTo(entryStream, BufferSize);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Un file sparito o bloccato non deve far saltare tutto l'invio.
|
||||
_logger.LogWarning(e, "Export log: file {File} saltato", item.File.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SendEmailDto.AttachmentsDto { FileName = fileName, FilePath = path };
|
||||
}
|
||||
|
||||
private void DeleteCurrentPartFiles()
|
||||
{
|
||||
foreach (var path in _currentPartFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Export log: impossibile eliminare {File}", path);
|
||||
}
|
||||
}
|
||||
|
||||
_currentPartFiles.Clear();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_workDir))
|
||||
Directory.Delete(_workDir, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Export log: impossibile eliminare {Dir}", _workDir);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user