From 3dfeef54eadbe5535dc6190ce464800a6ee1eae2 Mon Sep 17 00:00:00 2001 From: livio Date: Mon, 28 Sep 2026 19:42:26 +0200 Subject: [PATCH] Release 0.4.8 with Android file imports --- Components/Pages/Home.razor | 103 +++++++++++++++++++++++++++++- LMEMappeCatastaliApp.csproj | 18 ++++-- MauiProgram.cs | 1 + Platforms/Android/MainActivity.cs | 69 +++++++++++++++++++- README.md | 7 ++ Services/IncomingFileService.cs | 20 ++++++ global.json | 6 ++ scripts/build-android-test.ps1 | 51 +++++++++++++++ wwwroot/js/lme-map.js | 85 +++++++++++++++++------- 9 files changed, 328 insertions(+), 32 deletions(-) create mode 100644 Services/IncomingFileService.cs create mode 100644 global.json create mode 100644 scripts/build-android-test.ps1 diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index ebe56e7..a56e9b9 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -10,6 +10,7 @@ @inject MapExportService MapExportService @inject CloudSyncService CloudSyncService @inject AppUpdateService AppUpdateService +@inject IncomingFileService IncomingFiles @implements IAsyncDisposable LME Mappe Catastali @@ -697,6 +698,7 @@ private bool SyncBusy { get; set; } private string SyncStatus { get; set; } = "Sincronizzazione non configurata."; private bool UpdateBusy { get; set; } + private bool MapReady { get; set; } private string UpdateStatus { get; set; } = $"Versione installata: {AppUpdateService.CurrentVersion}"; private string UpdateDownloadUrl { get; set; } = string.Empty; private string UpdateReleaseNotes { get; set; } = string.Empty; @@ -754,6 +756,7 @@ protected override async Task OnInitializedAsync() { + IncomingFiles.FileAvailable += OnIncomingFileAvailable; LoadFlagPreferences(); try @@ -797,12 +800,14 @@ { MapCallbackReference = DotNetObjectReference.Create(this); await JS.InvokeVoidAsync("lmeMap.init", MapCallbackReference); + MapReady = true; await ApplyFlagPreferencesToMapAsync(); await ApplyAdvancedOptions(); await CenterOnInitialLocationAsync(); await MigrateLegacyProjectAsync(); var offlineCount = await JS.InvokeAsync("lmeMap.getOfflineTileStats"); OfflineStatus = offlineCount == 0 ? "Nessuna tessera salvata." : $"{offlineCount} tessere disponibili offline."; + await ProcessIncomingFilesAsync(); await InvokeAsync(StateHasChanged); } } @@ -1492,7 +1497,11 @@ private async Task RestoreBackup() { - var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli backup LME" }); + var file = await FilePicker.Default.PickAsync(new PickOptions + { + PickerTitle = "Scegli backup LME (.lmebackup.zip)", + FileTypes = BackupPickerFileTypes + }); if (file is null) return; var path = await CopyPickedFileToCacheAsync(file); var count = await ProjectStorageService.RestoreBackupAsync(path); @@ -1502,7 +1511,11 @@ private async Task ImportProject() { - var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli progetto LME" }); + var file = await FilePicker.Default.PickAsync(new PickOptions + { + PickerTitle = "Scegli progetto LME (.lmeproject.json)", + FileTypes = ProjectPickerFileTypes + }); if (file is null) return; var path = await CopyPickedFileToCacheAsync(file); var imported = await ProjectStorageService.ImportProjectAsync(path); @@ -1521,6 +1534,91 @@ return path; } + private void OnIncomingFileAvailable() + { + if (MapReady) _ = InvokeAsync(ProcessIncomingFilesAsync); + } + + private async Task ProcessIncomingFilesAsync() + { + while (IncomingFiles.TryDequeue(out var incoming) && incoming is not null) + { + try + { + var lowerName = incoming.FileName.ToLowerInvariant(); + if (lowerName.EndsWith(".kml", StringComparison.Ordinal) + || lowerName.EndsWith(".kmz", StringComparison.Ordinal) + || lowerName.EndsWith(".geojson", StringComparison.Ordinal) + || await IsGeoJsonAsync(incoming.Path)) + { + var content = Convert.ToBase64String(await File.ReadAllBytesAsync(incoming.Path)); + await JS.InvokeAsync("lmeMap.importExternalGisFile", incoming.FileName, content, incoming.ContentType); + continue; + } + + if (lowerName.EndsWith(".zip", StringComparison.Ordinal)) + { + var count = await ProjectStorageService.RestoreBackupAsync(incoming.Path); + await RefreshProjectsAsync(); + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Backup aperto: {count} progetti ripristinati."); + continue; + } + + if (lowerName.EndsWith(".json", StringComparison.Ordinal)) + { + var imported = await ProjectStorageService.ImportProjectAsync(incoming.Path); + await RefreshProjectsAsync(); + SelectedProjectId = imported.Id; + await RefreshProjectVersionsAsync(true); + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto aperto: {imported.Name}."); + continue; + } + + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Formato non riconosciuto: {incoming.FileName}."); + } + catch (Exception error) + { + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Impossibile aprire {incoming.FileName}: {error.GetBaseException().Message}"); + } + finally + { + try { File.Delete(incoming.Path); } catch { } + } + } + } + + private static async Task IsGeoJsonAsync(string path) + { + if (!Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase)) return false; + try + { + await using var stream = File.OpenRead(path); + using var document = await System.Text.Json.JsonDocument.ParseAsync(stream); + if (!document.RootElement.TryGetProperty("type", out var typeProperty)) return false; + var type = typeProperty.GetString(); + return type is "Feature" or "FeatureCollection" or "Point" or "MultiPoint" + or "LineString" or "MultiLineString" or "Polygon" or "MultiPolygon" + or "GeometryCollection"; + } + catch { return false; } + } + + private static readonly FilePickerFileType ProjectPickerFileTypes = new(new Dictionary> + { + [DevicePlatform.Android] = ["application/json", "text/json", "text/plain", "application/octet-stream"], + [DevicePlatform.WinUI] = [".json"], + [DevicePlatform.iOS] = ["public.json", "public.data"], + [DevicePlatform.macOS] = ["public.json", "public.data"] + }); + + private static readonly FilePickerFileType BackupPickerFileTypes = new(new Dictionary> + { + [DevicePlatform.Android] = ["application/zip", "application/x-zip-compressed", "application/octet-stream"], + [DevicePlatform.WinUI] = [".zip"], + [DevicePlatform.iOS] = ["public.zip-archive", "public.data"], + [DevicePlatform.macOS] = ["public.zip-archive", "public.data"] + }); + private async Task LoadProjectVersion() { if (SelectedProject is null || string.IsNullOrWhiteSpace(SelectedVersionId)) return; @@ -2121,6 +2219,7 @@ public ValueTask DisposeAsync() { + IncomingFiles.FileAvailable -= OnIncomingFileAvailable; CancelCatalogWork(); LocationService.StopTracking(); GpsLookupCancellation?.Cancel(); diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index c18fef1..1441427 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -1,8 +1,8 @@  - net9.0-android;net9.0-ios;net9.0-maccatalyst - $(TargetFrameworks);net9.0-windows10.0.19041.0 + net10.0-android;net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 @@ -28,8 +28,8 @@ com.companyname.lmemappecatastaliapp - 0.4.7 - 42 + 0.4.8 + 43 $(ApplicationDisplayVersion) @@ -43,6 +43,14 @@ 6.5 + + 36 + aab + true + false + true + + @@ -64,7 +72,7 @@ - + diff --git a/MauiProgram.cs b/MauiProgram.cs index 2feaba8..0fbb820 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -26,6 +26,7 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(IncomingFileService.Shared); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/Platforms/Android/MainActivity.cs b/Platforms/Android/MainActivity.cs index f42a71f..4fba63c 100644 --- a/Platforms/Android/MainActivity.cs +++ b/Platforms/Android/MainActivity.cs @@ -1,10 +1,75 @@ -using Android.App; +using Android.App; +using Android.Content; using Android.Content.PM; +using Android.Database; using Android.OS; +using Android.Provider; +using LMEMappeCatastaliApp.Services; namespace LMEMappeCatastaliApp; -[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/json")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/geo+json")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/geojson")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/vnd.google-earth.kml+xml")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/vnd.google-earth.kmz")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/xml")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "text/xml")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/zip")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/x-zip-compressed")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/octet-stream")] public class MainActivity : MauiAppCompatActivity { + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + QueueViewedFile(Intent); + } + + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + Intent = intent; + QueueViewedFile(intent); + } + + private void QueueViewedFile(Intent? intent) + { + if (intent?.Action == Android.Content.Intent.ActionView && intent.Data is not null) + { + _ = CopyViewedFileAsync(intent); + } + } + + private async Task CopyViewedFileAsync(Intent intent) + { + try + { + var uri = intent.Data!; + var fileName = ReadDisplayName(uri) ?? $"file-{Guid.NewGuid():N}"; + fileName = string.Concat(fileName.Select(character => Path.GetInvalidFileNameChars().Contains(character) ? '_' : character)); + var directory = Path.Combine(FileSystem.CacheDirectory, "Incoming"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, $"{Guid.NewGuid():N}-{fileName}"); + await using var input = ContentResolver!.OpenInputStream(uri) + ?? throw new IOException("Impossibile leggere il file selezionato."); + await using var output = File.Create(path); + await input.CopyToAsync(output); + IncomingFileService.Shared.Enqueue(new IncomingFile(path, fileName, intent.Type)); + } + catch (Exception error) + { + System.Diagnostics.Debug.WriteLine($"Apertura file LME non riuscita: {error}"); + } + } + + private string? ReadDisplayName(Android.Net.Uri uri) + { + if (uri.Scheme == ContentResolver.SchemeFile) return Path.GetFileName(uri.Path); + using ICursor? cursor = ContentResolver?.Query(uri, [IOpenableColumns.DisplayName], null, null, null); + if (cursor is null || !cursor.MoveToFirst()) return null; + var index = cursor.GetColumnIndex(IOpenableColumns.DisplayName); + return index >= 0 ? cursor.GetString(index) : null; + } } diff --git a/README.md b/README.md index d116c3a..69f1848 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS. +## Versione 0.4.8 + +- Aggiornamento a .NET 10 e target Android API 36. +- KML e KMZ generici conservano punti, linee e poligoni; il filtro particelle viene applicato soltanto ai file riconosciuti come catastali. +- Android propone LME tra le app disponibili per aprire KML, KMZ, GeoJSON, progetti JSON e backup ZIP. +- I selettori file Android mostrano correttamente progetti condivisi e backup con estensione composta. + ## Versione 0.4.7 - La ricerca nel pannello Layer conserva correttamente posizione e selezione del cursore durante il filtraggio; digitando `pio` il testo non viene piu invertito in `oip`. diff --git a/Services/IncomingFileService.cs b/Services/IncomingFileService.cs new file mode 100644 index 0000000..4e58480 --- /dev/null +++ b/Services/IncomingFileService.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; + +namespace LMEMappeCatastaliApp.Services; + +public sealed class IncomingFileService +{ + private readonly ConcurrentQueue _files = new(); + public static IncomingFileService Shared { get; } = new(); + public event Action? FileAvailable; + + public void Enqueue(IncomingFile file) + { + _files.Enqueue(file); + MainThread.BeginInvokeOnMainThread(() => FileAvailable?.Invoke()); + } + + public bool TryDequeue(out IncomingFile? file) => _files.TryDequeue(out file); +} + +public sealed record IncomingFile(string Path, string FileName, string? ContentType); diff --git a/global.json b/global.json new file mode 100644 index 0000000..f3e8020 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestPatch" + } +} diff --git a/scripts/build-android-test.ps1 b/scripts/build-android-test.ps1 new file mode 100644 index 0000000..bc0ddcf --- /dev/null +++ b/scripts/build-android-test.ps1 @@ -0,0 +1,51 @@ +param([string]$Configuration = "Release") + +$ErrorActionPreference = "Stop" +$projectRoot = Split-Path -Parent $PSScriptRoot +$dotnet = Join-Path $env:USERPROFILE ".dotnet10\dotnet.exe" +$androidSdk = Join-Path $env:LOCALAPPDATA "Android\Sdk" +$keyStore = Join-Path $env:USERPROFILE ".android\debug.keystore" +$outputDirectory = Join-Path $projectRoot "artifacts\android-test" +$projectPath = Join-Path $projectRoot "LMEMappeCatastaliApp.csproj" +[xml]$projectXml = Get-Content -Raw -LiteralPath $projectPath +$appVersion = ($projectXml.Project.PropertyGroup | Where-Object ApplicationDisplayVersion | Select-Object -First 1).ApplicationDisplayVersion + +foreach ($requiredPath in @($dotnet, $androidSdk, $keyStore)) { + if (-not (Test-Path -LiteralPath $requiredPath)) { throw "Componente richiesto non trovato: $requiredPath" } +} + +New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +$packages = [System.Collections.Generic.List[System.IO.FileInfo]]::new() +foreach ($format in @("apk", "aab")) { + $publishArguments = @( + "publish", $projectPath, + "-f", "net10.0-android", + "-c", $Configuration, + "-p:AndroidSdkDirectory=$androidSdk", + "-p:AndroidPackageFormat=$format", + "-p:AndroidKeyStore=true", + "-p:AndroidSigningKeyStore=$keyStore", + "-p:AndroidSigningKeyAlias=androiddebugkey", + "-p:AndroidSigningStorePass=android", + "-p:AndroidSigningKeyPass=android" + ) + & $dotnet @publishArguments + if ($LASTEXITCODE -ne 0) { throw "Pubblicazione Android $format non riuscita." } + + $publishDirectory = Join-Path $projectRoot "bin\$Configuration\net10.0-android\publish" + $package = Get-ChildItem -LiteralPath $publishDirectory -Filter "*-Signed.$format" -File | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($null -eq $package) { throw "Nessun pacchetto $format firmato prodotto." } + + $destination = Join-Path $outputDirectory "LME-Mappe-Catastali-$appVersion.$format" + Copy-Item -LiteralPath $package.FullName -Destination $destination -Force + $packages.Add((Get-Item -LiteralPath $destination)) +} + +$packages | ForEach-Object { + $hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName + "$($hash.Hash.ToLowerInvariant()) $($_.Name)" +} | Set-Content -LiteralPath (Join-Path $outputDirectory "SHA256SUMS.txt") -Encoding ascii + +$packages | Select-Object Name, Length, LastWriteTime diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index 73ef12a..2d21925 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -167,6 +167,7 @@ window.lmeMap = { clearOfflineTiles, getOfflineTileStats, resize, + importExternalGisFile, showInfo: setInfo }; @@ -737,29 +738,7 @@ function bindFileImport() { if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione"); setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`); await new Promise(resolve => window.setTimeout(resolve, 20)); - const result = await readGisFile(file); - rememberMapChange(); - const geojson = result.geojson; - const parcelLayer = addImportedFeatures(geojson); - const sheetGeojson = result.sheetGeojson || emptyFeatureCollection(); - const sheetLayer = addImportedSheetFeatures(sheetGeojson); - applyImportedSheetVisibility(); - fitLayer(geojson.features.length ? parcelLayer : sheetLayer); - refreshImportedMeasurements(); - const featureCount = geojson.features.length; - const sheetCount = sheetGeojson.features.length; - const ignored = result.ignoredCount - ? `; esclusi ${result.ignoredCount} elementi del foglio o non particella` - : ""; - const includedSheets = sheetCount - ? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}` - : ""; - const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : ""; - const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : ""; - setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`); - updateGpsInsideState(); - refreshLayerManager(); - finishMapChange(); + await importGisFile(file); } catch (error) { setInfo(`File non leggibile: ${error.message}`); } finally { @@ -769,6 +748,41 @@ function bindFileImport() { lmeState.fileImportBound = true; } +async function importExternalGisFile(fileName, base64Content, mimeType) { + const binary = atob(base64Content); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); + const file = new File([bytes], fileName, { type: mimeType || "application/octet-stream" }); + if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione"); + setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`); + await new Promise(resolve => window.setTimeout(resolve, 20)); + return importGisFile(file); +} + +async function importGisFile(file) { + const result = await readGisFile(file); + rememberMapChange(); + const geojson = result.geojson; + const parcelLayer = addImportedFeatures(geojson); + const sheetGeojson = result.sheetGeojson || emptyFeatureCollection(); + const sheetLayer = addImportedSheetFeatures(sheetGeojson); + applyImportedSheetVisibility(); + fitLayer(geojson.features.length ? parcelLayer : sheetLayer); + refreshImportedMeasurements(); + const featureCount = geojson.features.length; + const sheetCount = sheetGeojson.features.length; + const ignored = result.ignoredCount ? `; esclusi ${result.ignoredCount} elementi del foglio o non particella` : ""; + const includedSheets = sheetCount ? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}` : ""; + const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : ""; + const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : ""; + const message = `Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`; + setInfo(message); + updateGpsInsideState(); + refreshLayerManager(); + finishMapChange(); + return message; +} + async function readGisFile(file) { const lowerName = file.name.toLowerCase(); @@ -985,6 +999,15 @@ function normalizeKmlFiles(files) { throw new Error("KML senza geometrie utilizzabili"); } + if (!looksLikeCadastralKml(features)) { + return { + geojson: { type: "FeatureCollection", features }, + sheetGeojson: emptyFeatureCollection(), + ignoredCount: 0, + reprojected: false + }; + } + const polygonParts = splitKmlPolygonFeatures(features); const parcels = filterKmlParcelFeatures(polygonParts); const parcelSet = new Set(parcels); @@ -1005,6 +1028,22 @@ function normalizeKmlFiles(files) { }; } +function looksLikeCadastralKml(features) { + return features.some(feature => { + const properties = feature?.properties || {}; + const name = String(properties.name || properties.Name || properties.LABEL || "").trim(); + const context = [ + name, + properties.layer, + properties.Layer, + properties.type, + properties.Tipo, + properties._lmeFolder + ].filter(Boolean).join(" ").toLowerCase(); + return /\b(particell|mappal|catast|foglio|parcel|cadastral)\w*/i.test(context); + }); +} + function kmlTextToGeoJson(text, sourceName) { const xml = new DOMParser().parseFromString(text, "text/xml"); if (xml.getElementsByTagName("parsererror").length) {