Compare commits

..
24 Commits
Author SHA1 Message Date
livio 62f28c5621 build: include license manager in release checksums 2026-09-28 09:58:35 +02:00
livio 11a91863a3 feat: prepare Pro licensing and Android API 36 2026-09-28 09:50:58 +02:00
livio b600412285 Fix layer search cursor and refresh project revisions 2026-09-25 22:44:54 +02:00
livio f65451c414 Load distinct official cadastral sheet boundaries 2026-09-25 21:33:53 +02:00
livio f480ce23ca Release 0.4.5 with metadata tools and responsive layers 2026-09-25 21:10:19 +02:00
livio 00053e4d5f Fix layer actions and add reusable color editors 2026-09-25 15:40:20 +02:00
livio cfba1b998b Release 0.4.3 with contextual help and parcel deduplication 2026-09-25 14:13:37 +02:00
livio f1224b21c4 Aggiunge spostamento tra gruppi 0.4.2 2026-09-25 08:59:38 +02:00
livio eb5f51795b Migliora layer e persistenza nomi 0.4.1 2026-09-25 08:25:25 +02:00
livio 205a520708 Rilascia gestione completa progetti e aggiornamenti 0.4.0 2026-09-24 17:21:25 +02:00
livio 620a4bec26 Rilascia strumenti avanzati e archivio 0.3.0 2026-09-24 16:15:23 +02:00
livio b19afbce91 Scegli ambito per modifica ed eliminazione 2026-09-24 14:42:39 +02:00
livio 28a982abed Modifica ed elimina il disegno selezionato 2026-09-24 14:20:46 +02:00
livio 0ffcc7ae31 Distingui visivamente la gerarchia dei layer 2026-09-24 09:21:08 +02:00
livio bb30f23d67 Riorganizza verticalmente il pannello layer 2026-09-24 09:04:13 +02:00
livio 87304b1568 Rimuovi oggetti e gruppi dal progetto 2026-09-24 08:50:31 +02:00
livio a23eff6124 Rinomina ed evidenzia i gruppi mappa 2026-09-24 08:38:07 +02:00
livio 4da47a8835 Consenti di rinominare gli oggetti mappa 2026-09-24 08:15:58 +02:00
livio fc303e2555 Separa le misure per ogni oggetto 2026-09-22 16:56:17 +02:00
livio 49ed7d5f0e Mantieni aperto il gestore livelli al refresh 2026-09-22 16:15:12 +02:00
livio 3856a9f675 Correggi deselezione oggetti mappa 2026-09-22 16:01:35 +02:00
livio e1bb7d9320 Correggi visibilità oggetti durante refresh GPS 2026-09-22 15:47:54 +02:00
livio f1bbd53634 Aggiungi gestore gerarchico livelli mappa 2026-09-22 15:26:56 +02:00
livio 708f539c64 Persist map flags and toggle selected parcels 2026-09-22 14:43:27 +02:00
36 changed files with 8707 additions and 281 deletions

No files matched your search

+1
View File
@@ -14,3 +14,4 @@ obj/
*.p12 *.p12
*.log *.log
TestResults/ TestResults/
artifacts/
+117
View File
@@ -0,0 +1,117 @@
@inject AppLocalizationService L10n
<details class="menu-help">
<summary>
<span aria-hidden="true">?</span>
<strong>@L10n.T("menu.quickHelp")</strong>
</summary>
<div class="menu-help-content">
<p>@Introduction</p>
<ol>
@foreach (var instruction in Instructions)
{
<li>@instruction</li>
}
</ol>
</div>
</details>
@code {
[Parameter, EditorRequired]
public string Topic { get; set; } = string.Empty;
private string Introduction => Topic switch
{
"layers" => "Qui controlli visibilita, ordine, selezione e organizzazione di tutti gli oggetti presenti sulla mappa.",
"cadastre" => "Trova una particella nel catalogo catastale e mostrane il confine ufficiale sulla mappa.",
"data" => "Importa file GIS, crea disegni e misure oppure aggiungi punti di interesse.",
"save" => "Esporta gli elementi della mappa in un file compatibile con altre applicazioni GIS e GPS.",
"projects" => "Salva il lavoro completo, riaprilo, organizzalo e recupera revisioni o copie di sicurezza.",
"tools" => "Consulta e gestisci gli oggetti in modo avanzato, controlla le coordinate e prepara mappe offline.",
"settings" => "Personalizza mappa, salvataggi, GPS, sincronizzazione e servizi esterni.",
_ => "Consulta le indicazioni essenziali per usare questo pannello."
};
private IReadOnlyList<string> Instructions => Topic switch
{
"layers" =>
[
"Visibilita: usa la casella di una categoria o di un gruppo per mostrare o nascondere tutti i suoi contenuti; la casella del singolo oggetto agisce solo su quello.",
"Gerarchia: premi la freccia accanto alla maniglia per aprire un gruppo. Il rientro, il carattere e la tonalita indicano se una riga e figlia, nipote o di livello successivo.",
"Selezione multipla: attiva il flag sulle righe desiderate. Esempio: seleziona tre poligoni per esportare solo quelli oppure per bloccarli con una sola operazione.",
"Rinomina e attributi: Rinomina modifica il nome mostrato e salvato nei progetti e nei file esportati; Attributi apre un editor multilinea per consultare o correggere i dati associati.",
"Colori: espandi un padre o un figlio e usa gli editor di linea e riempimento. Puoi scegliere il colore, prelevarlo con il contagocce se supportato, oppure inserire e copiare i valori HEX, RGB e HSL; sul padre viene applicato a tutto il gruppo.",
"Blocco: il lucchetto impedisce modifiche e spostamenti involontari. Sblocca l'oggetto prima di usare la matita o gli strumenti geometrici.",
"Sposta nel gruppo cambia l'appartenenza. Esempio: trasferisci un poligono da Disegni e POI a Progetti e dati importati, oppure in un nuovo sottogruppo chiamato Rilievo nord.",
"Sposta geometria cambia invece le coordinate dell'oggetto, per esempio 100 metri verso nord; non modifica il gruppo di appartenenza.",
"Rimuovi dal progetto nasconde l'elemento e lo esclude dai salvataggi successivi; Elimina lo sposta nel cestino. Usa gli strumenti avanzati per ripristinare l'ultima rimozione o svuotare definitivamente il cestino.",
"Ordine: trascina la maniglia di una riga verso l'alto o il basso per cambiare la sovrapposizione sulla mappa."
],
"cadastre" =>
[
"Compila i campi nell'ordine Regione, Comune, Foglio e Particella: ogni scelta carica l'elenco necessario al campo successivo.",
"Esempio: scegli Campania, il Comune, il foglio 12 e la particella 345, poi premi Mostra particella sulla mappa.",
"Il confine ufficiale viene evidenziato sulla mappa. Se Centra e zooma quando seleziono un oggetto e attivo nelle Impostazioni, la mappa lo inquadra automaticamente.",
"Se la particella e gia presente tra disegni, progetti o file GeoJSON, KML e KMZ, l'app riconosce il riferimento catastale o la geometria e riutilizza l'oggetto esistente senza duplicarlo.",
"Se un oggetto gia presente era nascosto, viene reso nuovamente visibile. La visibilita generale dei contorni evidenziati e dei dati importati resta controllabile dal pannello Layer.",
"Quando il servizio ufficiale non restituisce subito il confine, attendi il completamento indicato nel pannello e riprova mantenendo gli stessi dati selezionati."
],
"data" =>
[
"Importazione: premi Carica KML, KMZ o GeoJSON e scegli il file dal dispositivo. I nomi e gli attributi riconosciuti vengono mantenuti.",
"Per KML e KMZ, Includi anche il layer del foglio conserva le cornici presenti nel file. Mostra cornice/layer del foglio recupera inoltre dal servizio ufficiale una cornice distinta per ciascun foglio delle particelle, senza creare un unico riquadro tra fogli lontani.",
"Usa Mostra dati importati e Mostra cornice per controllare separatamente la visibilita. Inquadra dati porta la mappa sull'estensione completa del file.",
"Disegno: premi Mostra strumenti sulla mappa, torna alla mappa e scegli linea, poligono, rettangolo, cerchio o POI nella barra Leaflet.",
"Modifica ed eliminazione possono agire sull'oggetto selezionato o su tutti i disegni. Prima seleziona sulla mappa la linea o il poligono interessato, poi scegli l'ambito dal piccolo comando accanto alla matita o al cestino.",
"Annulla e Ripristina coprono disegni, spostamenti, rinomine ed eliminazioni recenti. Recupera ultimo autosalvataggio ricarica invece l'ultima copia locale creata automaticamente.",
"Esempio POI: porta il centro della mappa sull'ingresso di un terreno, scrivi Cancello nord e premi Aggiungi POI al centro mappa.",
"Per rinominare, bloccare o trasferire un oggetto in un altro gruppo, apri Layer e usa i comandi della relativa riga."
],
"save" =>
[
"Inserisci un nome riconoscibile, per esempio Rilievo particelle 12-345, poi scegli il formato in base all'uso previsto.",
"GeoJSON e adatto ai software GIS e conserva geometrie, nomi e attributi; KML e KMZ sono adatti a Google Earth, con KMZ piu compatto per invio e archiviazione.",
"GPX e pensato per dispositivi e applicazioni GPS; PDF crea un rapporto consultabile e stampabile invece di un file vettoriale modificabile.",
"Attiva solo le categorie da includere. Esempio: per consegnare disegni e particelle importate senza la traccia GPS, lascia attive quelle due voci e disattiva Traccia GPS.",
"Solo oggetti selezionati esporta esclusivamente le righe contrassegnate nel pannello Layer. Se non devi filtrare manualmente, lascia questa opzione disattivata.",
"Premi Esporta e salva file e scegli la cartella o l'app di destinazione proposta dal dispositivo.",
"Nomi rinominati e attributi vengono salvati in GeoJSON, KML e KMZ. Gli sfondi satellitari, stradali e WMS non vengono incorporati nei file vettoriali."
],
"projects" =>
[
"Nuovo progetto: inserisci un nome, scegli se includere i contorni catastali evidenziati e premi Salva progetto. Vengono conservati oggetti, gruppi, nomi, attributi e opzioni di visualizzazione.",
"Aggiornamento: dopo aver aperto un progetto, usa di nuovo Salva progetto con lo stesso nome per aggiornarlo. Se nelle Impostazioni attivi Aggiorna automaticamente il progetto aperto, le modifiche vengono registrate dopo 3 secondi di inattivita.",
"Cartelle: seleziona un progetto, scrivi per esempio Cantieri 2026 e premi Sposta per organizzarlo nell'archivio; questa operazione non sposta le geometrie sulla mappa.",
"Rinomina cambia il nome del progetto selezionato senza alterarne contenuti e revisioni. Duplica crea invece una copia modificabile, utile prima di una variante importante o quando ricevi un progetto in sola lettura.",
"Archivia nasconde logicamente i lavori conclusi senza eliminarli; Ripristina li riporta tra quelli attivi. Elimina rimuove invece il progetto dall'archivio.",
"Condividi modificabile consente al destinatario di continuare il lavoro; Condividi in sola lettura protegge l'originale e richiede una duplicazione per modificarlo.",
"Revisioni: la prima viene creata quando salvi per la seconda volta un progetto gia esistente. L'elenco si aggiorna anche dopo i salvataggi automatici; seleziona una data e usa Apri revisione oppure Confronta.",
"Backup: Crea backup raccoglie tutti i progetti in un file trasferibile. Ripristina backup ricarica un archivio completo; Importa progetto aggiunge invece un singolo progetto condiviso."
],
"tools" =>
[
"Tabella oggetti: apre una vista ordinata di particelle, file importati e disegni. Usa i flag per selezionare piu righe, il lucchetto per proteggerle e la matita per modificare i metadati.",
"Copia metadati: nella maschera Metadati oggetto scegli Copia da e l'oggetto sorgente. Attiva Importa anche i valori per copiare nomi e contenuti; disattivalo per creare soltanto gli stessi campi, lasciandoli vuoti o mantenendo i valori gia presenti.",
"Coordinate WGS84 e UTM: scegli un punto sulla mappa per leggere latitudine e longitudine oppure le coordinate metriche UTM, utili per rilievi e confronti tecnici.",
"Registro operazioni: mostra in ordine cronologico disegni, modifiche, rinomine, spostamenti ed eliminazioni, cosi puoi capire cosa e cambiato durante la sessione.",
"Cestino: Ripristina ultima rimozione recupera l'elemento eliminato piu recente. Svuota cestino elimina definitivamente tutti gli elementi rimossi e non consente ulteriori recuperi.",
"Mappe offline: prima inquadra sulla mappa la zona che ti servira sul campo, quindi scegli quanti livelli di zoom aggiuntivi scaricare. Valori maggiori offrono piu dettaglio ma richiedono piu spazio e tempo.",
"Esempio offline: inquadra il cantiere e le strade di accesso, imposta 2 livelli aggiuntivi e premi Scarica area visibile mentre hai connessione.",
"Usa mappe offline seleziona lo sfondo salvato localmente; Svuota archivio libera lo spazio occupato dalle tessere scaricate."
],
"settings" =>
[
"Mappa e sfondo: scegli la cartografia di base e regola Trasparenza catasto per leggere meglio confini e immagini sottostanti. Esempio: riducila quando le linee coprono dettagli del terreno.",
"Autosalvataggio locale dopo ogni modifica: se attivo, ogni disegno, modifica, rinomina o eliminazione aggiorna immediatamente una copia di recupero nel dispositivo. Per usarla apri Dati e disegni e premi Recupera ultimo autosalvataggio.",
"Aggiorna automaticamente il progetto aperto: funziona solo dopo aver aperto o salvato un progetto modificabile. Dopo una modifica attende 3 secondi senza altre operazioni e aggiorna quel progetto; un progetto in sola lettura non viene mai sovrascritto.",
"Backup automatico: scegli Giornaliero o Settimanale. Alla successiva pressione di Salva progetto, se e trascorso l'intervallo, viene creata una copia interna di tutti i progetti; l'app conserva le 10 copie automatiche piu recenti. Disattivato non crea nuove copie programmate.",
"Esempio consigliato: lascia attivo l'autosalvataggio locale, attiva l'aggiornamento del progetto aperto durante i rilievi e imposta un backup giornaliero. Prima di modifiche importanti crea anche un backup manuale dal menu Progetti.",
"Aggancio ai vertici: avvicinando un nuovo vertice a uno esistente, l'app lo allinea entro la distanza in pixel scelta. Aumenta la distanza su schermi piccoli; riducila quando ci sono molti vertici ravvicinati.",
"Centra e zooma quando seleziono un oggetto: attivalo per inquadrare automaticamente ogni elemento scelto; disattivalo se vuoi mantenere ferma la vista mentre lavori dall'elenco Layer.",
"WebDAV: inserisci indirizzo, utente e password applicazione, salva la configurazione, poi usa Invia al cloud per caricare il backup o Scarica dal cloud per ripristinare quello remoto.",
"GPS e percorso: Mostra punto visualizza posizione e precisione; Registra traccia memorizza il cammino; distanza e avvisi aiutano a controllare l'avvicinamento e l'attraversamento del confine selezionato.",
"Google Maps e Azure Maps richiedono le rispettive chiavi solo quando scegli quegli sfondi. Controlla nuova versione confronta la versione installata con l'ultima pubblicata e mostra le relative modifiche."
],
_ => []
};
}
File diff suppressed because it is too large. Load diff
+36 -8
View File
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Razor"> <Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks> <TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks> <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET --> <!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> --> <!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
@@ -22,14 +22,15 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Display name --> <!-- Display name -->
<ApplicationTitle>LME Mappe Catastali</ApplicationTitle> <ApplicationTitle>LME Mappe Catastali Pro</ApplicationTitle>
<!-- App Identifier --> <!-- App Identifier -->
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId> <ApplicationId>it.liviomerola.lmemappecatastali.pro</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>0.2.16</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<ApplicationVersion>20</ApplicationVersion> <ApplicationVersion>100</ApplicationVersion>
<Version>$(ApplicationDisplayVersion)</Version>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged --> <!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>None</WindowsPackageType>
@@ -42,9 +43,21 @@
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidUseAapt2>true</AndroidUseAapt2>
<AndroidCreatePackagePerAbi>false</AndroidCreatePackagePerAbi>
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' and '$(AndroidSigningKeyStore)' != ''">
<AndroidKeyStore>true</AndroidKeyStore>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- App Icon --> <!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" /> <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#075E54" />
<!-- Splash Screen --> <!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" /> <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
@@ -63,8 +76,23 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" /> <PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.12" />
<PackageReference Include="Parquet.Net" Version="6.1.0" /> <PackageReference Include="Parquet.Net" Version="6.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<PackageReference Include="Xamarin.Android.Google.BillingClient" Version="9.1.0.2" />
<PackageReference Include="Xamarin.AndroidX.Collection.Ktx" Version="1.6.0.1" />
<PackageReference Include="Xamarin.AndroidX.Fragment.Ktx" Version="1.9.0" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData" Version="2.11.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData.Core.Ktx" Version="2.11.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
</ItemGroup>
<ItemGroup>
<Compile Remove="tools\**\*.cs" />
<EmbeddedResource Remove="tools\**\*" />
<None Remove="tools\**\*" />
</ItemGroup>
</Project> </Project>
+44
View File
@@ -0,0 +1,44 @@
# Licenze manuali LME Pro
## Regola di accesso
L'app considera Pro l'utente quando e valida almeno una delle due licenze:
- abbonamento Google Play attivo;
- licenza manuale crittografata e non scaduta.
Senza una licenza valida l'app resta Base. Alla scadenza della licenza manuale le funzioni Pro vengono disabilitate automaticamente, senza cancellare o alterare i dati dell'utente. Un abbonamento Google valido continua a dare accesso Pro anche se la licenza manuale e scaduta.
## Emissione
Il gestore si trova in `tools/LmeLicenseManager` e registra in SQLite:
- nome e cognome;
- email;
- identificativo univoco della licenza;
- data di emissione e scadenza;
- hash e codice emesso;
- note e data di eventuale revoca amministrativa.
Il codice `LME1...` e cifrato con AES-GCM e firmato con RSA 3072. L'utente lo incolla nella sezione Licenza manuale delle impostazioni. L'app possiede solo la chiave pubblica RSA e non puo generare codici validi.
## Portabilita della chiave
La licenza non e vincolata al PC che la genera, all'account Windows o al dispositivo dell'utente finale. La chiave privata operativa e protetta localmente con Windows DPAPI, ma dal gestore puo essere esportata come file `.lmekey` cifrato con una password di almeno 12 caratteri e importata su un altro PC o da un altro utente Windows.
Conservare il file `.lmekey` e la password in luoghi separati e protetti. Senza questo backup non e possibile ricreare la stessa autorita di firma su un nuovo sistema.
Percorsi locali predefiniti:
```text
%LOCALAPPDATA%\LME\LicenseManager\licenses.db
%LOCALAPPDATA%\LME\LicenseManager\manual-license-private.dpapi
```
Il database puo essere copiato per conservare lo storico. La chiave DPAPI non e un backup portabile: per la portabilita usare esclusivamente l'esportazione `.lmekey`.
## Limiti offline
Una licenza manuale gia consegnata viene verificata interamente offline. Marcarla come revocata nel database conserva l'audit amministrativo, ma non puo disattivare a distanza un codice gia installato: rimane valido fino alla sua scadenza. Una revoca immediata richiederebbe un servizio online di verifica.
Chiavi private, backup `.lmekey`, database delle licenze e password non devono mai essere inseriti nel repository o negli artefatti pubblici.
+10
View File
@@ -24,6 +24,16 @@ public static class MauiProgram
builder.Services.AddSingleton<AzureMapsService>(); builder.Services.AddSingleton<AzureMapsService>();
builder.Services.AddSingleton<ProjectStorageService>(); builder.Services.AddSingleton<ProjectStorageService>();
builder.Services.AddSingleton<MapExportService>(); builder.Services.AddSingleton<MapExportService>();
builder.Services.AddSingleton<CloudSyncService>();
builder.Services.AddSingleton<AppUpdateService>();
builder.Services.AddSingleton<AppLocalizationService>();
builder.Services.AddSingleton<ManualLicenseService>();
#if ANDROID
builder.Services.AddSingleton<IPlayBillingService, Platforms.Android.AndroidPlayBillingService>();
#else
builder.Services.AddSingleton<IPlayBillingService, UnavailablePlayBillingService>();
#endif
builder.Services.AddSingleton<EntitlementService>();
#if DEBUG #if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); builder.Services.AddBlazorWebViewDeveloperTools();
+128
View File
@@ -0,0 +1,128 @@
# LME Mappe Catastali Pro - Play Store Checklist
## Identita app
- Nome: LME Mappe Catastali Pro.
- Package name Android: `it.liviomerola.lmemappecatastali.pro`.
- Versione iniziale Play: `1.0.0` / version code `100`.
- Formato Release Android: Android App Bundle (`.aab`).
- Target SDK Release: Android 16 / API 36.
## Base e Pro
La separazione e centralizzata in `Services/EntitlementService.cs`.
Base gratuita:
- consultazione mappa catastale;
- ricerca localita e ricerca catastale;
- GPS live;
- disegni e misure base;
- apertura e consultazione dei dati gia presenti.
Pro:
- archivio progetti;
- export GIS/PDF/GPX;
- sincronizzazione WebDAV;
- mappe offline;
- strumenti geometrici avanzati;
- traccia GPS.
Se l'abbonamento non risulta verificabile offline, l'app conserva una tolleranza locale e non elimina, sequestra o altera i dati dell'utente. Le funzioni Pro possono essere disabilitate, ma i dati esistenti devono restare consultabili.
## Google Play Billing
Configurare nella Play Console un unico prodotto in abbonamento:
- product id: `lme_pro`;
- base plan mensile: `monthly`, prezzo discusso `EUR 4,99`;
- base plan annuale: `yearly`, prezzo discusso `EUR 39,99`.
Il codice contiene solo identificativi di prodotto e base plan. Non inserire chiavi casuali, segreti, service account, keystore o password nel repository.
Il client Android usa Google Play Billing 9.1 tramite `Xamarin.Android.Google.BillingClient 9.1.0.2`. Acquisto, ripristino, acknowledgement e interrogazione delle sottoscrizioni sono collegati a `EntitlementService`.
Prima della produzione serve ancora la verifica server-side dei purchase token. La verifica solo sul dispositivo e adatta ai test interni, ma non e sufficiente come protezione definitiva.
## Licenze manuali
- Codice opaco cifrato AES-GCM e firmato RSA 3072, inseribile nelle impostazioni dell'app.
- Registro SQLite con nome, cognome, email, emissione, scadenza, hash, note e revoca amministrativa.
- Ritorno automatico a Base alla scadenza, salvo abbonamento Google ancora valido.
- Nessun vincolo al PC emittente o al dispositivo destinatario.
- Backup portabile `.lmekey` della chiave privata, cifrato da password, per l'uso da altri sistemi o utenti Windows.
Dettagli operativi e limiti della revoca offline sono in `MANUAL_LICENSES.md`.
## Firma Android
Usare Play App Signing.
Da fare fuori repository:
- creare upload key e keystore in una cartella privata;
- salvare password in secret manager, variabili ambiente o CI secret;
- configurare `AndroidSigningKeyStore`, `AndroidSigningKeyAlias`, `AndroidSigningKeyPass` e `AndroidSigningStorePass` solo in ambiente locale/CI;
- caricare la upload certificate nella Play Console quando richiesto.
## Privacy e permessi
Permessi Android dichiarati:
- Internet e stato rete;
- posizione approssimativa e precisa;
- Billing.
La privacy policy deve spiegare:
- uso GPS e posizione per mappa, particelle, tracce e misure;
- uso rete per mappe, servizi catastali pubblici, geocoding, sync WebDAV e aggiornamenti;
- gestione locale di progetti, backup, mappe offline e preferenze;
- eventuali chiavi personali Google/Azure salvate nel SecureStorage del dispositivo;
- acquisti gestiti da Google Play.
## Localizzazione e misure
La localizzazione applicativa e in `Services/AppLocalizationService.cs`.
Lingue iniziali:
- Italiano;
- Inglese.
Sistemi di misura:
- automatico in base all'area;
- metrico;
- imperiale.
La mappa JavaScript riceve cultura e sistema di misura tramite `lmeMap.setLocalization(...)`; distanze, superfici e Leaflet Draw vengono formattati di conseguenza.
## Build Release
Esempio locale, con Android SDK installato:
```powershell
dotnet publish -f net10.0-android -c Release -p:AndroidSdkDirectory=C:\Users\livio\AppData\Local\Android\Sdk
```
Per firmare aggiungere solo parametri esterni o variabili ambiente, mai valori nel file `.csproj`.
Per creare APK e AAB di test con una chiave locale separata:
```powershell
.\scripts\build-android-test.ps1
```
La chiave di test non deve essere usata come upload key del Play Store.
## Interventi richiesti sull'account Google
- aprire o confermare l'account sviluppatore Play Console;
- creare l'app con package `it.liviomerola.lmemappecatastali.pro`;
- creare `lme_pro` con i base plan `monthly` e `yearly` e impostarne prezzi e paesi;
- attivare Play App Signing e creare una upload key di produzione;
- configurare tester, traccia di test interno e account licenza;
- predisporre credenziali e servizio per la verifica server-side dei purchase token;
- pubblicare la privacy policy su un URL pubblico e completare Data safety, scheda store e classificazione contenuti.
+27
View File
@@ -0,0 +1,27 @@
# Informativa privacy - bozza
Questa bozza deve essere completata con i dati del titolare, i contatti, la data di efficacia e un URL pubblico prima della pubblicazione su Google Play.
## Dati trattati
LME Mappe Catastali usa la posizione del dispositivo per mostrare la posizione sulla mappa, verificare l'inclusione nelle geometrie e, solo su comando dell'utente, registrare una traccia GPS. Progetti, disegni, mappe offline, preferenze e backup sono conservati sul dispositivo o nelle destinazioni scelte dall'utente.
Le licenze manuali contengono nome, cognome, email, identificativo e scadenza. Questi dati sono cifrati nel codice licenza e conservati localmente nell'area sicura dell'app. Il gestore usato dal titolare registra gli stessi dati in un database amministrativo locale.
Gli acquisti Google Play sono gestiti da Google. L'app interroga lo stato della sottoscrizione per abilitare le funzioni Pro.
## Servizi di rete
L'app puo contattare servizi cartografici e catastali pubblici, provider di geocoding e mappe, Azure Maps quando configurato dall'utente, e un server WebDAV scelto dall'utente. Indirizzi di ricerca, coordinate, indirizzi IP e dati tecnici possono quindi essere trattati dai rispettivi fornitori secondo le loro informative.
Credenziali WebDAV e chiavi personali configurate nell'app sono conservate nel SecureStorage del dispositivo e non sono incluse nel pacchetto applicativo.
## Conservazione e controllo
L'utente puo eliminare progetti, cache, mappe offline, credenziali e licenza manuale dall'app o cancellando i dati applicativi. La scadenza o perdita della licenza Pro non cancella i contenuti esistenti.
Il registro amministrativo delle licenze manuali deve essere conservato solo per il tempo necessario alla gestione contrattuale, fiscale e di assistenza, con accesso limitato e backup protetti.
## Permessi Android
L'app richiede accesso a Internet e stato della rete, posizione approssimativa e precisa e Google Play Billing. La registrazione della traccia viene avviata dall'utente.
+2 -1
View File
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application> <application android:allowBackup="true" android:icon="@mipmap/appicon" android:label="LME Mappe Catastali Pro" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="com.android.vending.BILLING" />
</manifest> </manifest>
@@ -0,0 +1,201 @@
using Android.BillingClient.Api;
using LMEMappeCatastaliApp.Services;
using Microsoft.Maui.ApplicationModel;
namespace LMEMappeCatastaliApp.Platforms.Android;
public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingService, IPurchasesUpdatedListener
{
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly BillingClient _client;
private TaskCompletionSource<PlayBillingResult>? _purchaseCompletion;
public AndroidPlayBillingService()
{
var pendingPurchases = PendingPurchasesParams.NewBuilder()
.EnableOneTimeProducts()
.EnablePrepaidPlans()
.Build();
_client = BillingClient.NewBuilder(global::Android.App.Application.Context)
.SetListener(this)
.EnablePendingPurchases(pendingPurchases)
.EnableAutoServiceReconnection()
.Build();
}
public async Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default)
{
try
{
await EnsureConnectedAsync(cancellationToken);
var query = QueryPurchasesParams.NewBuilder()
.SetProductType(BillingClient.ProductType.Subs)
.Build();
var result = await _client.QueryPurchasesAsync(query);
if (result.Result.ResponseCode != BillingResponseCode.Ok)
{
return PlayBillingResult.Failed(ToMessage(result.Result));
}
var purchase = result.Purchases.FirstOrDefault(IsPurchasedProSubscription);
if (purchase is null)
{
return PlayBillingResult.Base("Nessun abbonamento LME Pro attivo trovato su Google Play.");
}
var acknowledgement = await AcknowledgeIfNeededAsync(purchase);
return acknowledgement ?? PlayBillingResult.Active("Abbonamento LME Pro ripristinato da Google Play.");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception error)
{
return PlayBillingResult.Failed($"Google Play Billing non disponibile: {error.GetBaseException().Message}");
}
}
public async Task<PlayBillingResult> PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default)
{
try
{
await EnsureConnectedAsync(cancellationToken);
var product = QueryProductDetailsParams.Product.NewBuilder()
.SetProductId(EntitlementService.ProductId)
.SetProductType(BillingClient.ProductType.Subs)
.Build();
var query = QueryProductDetailsParams.NewBuilder()
.SetProductList([product])
.Build();
var detailsResult = await _client.QueryProductDetailsAsync(query);
if (detailsResult.Result.ResponseCode != BillingResponseCode.Ok)
{
return PlayBillingResult.Failed(ToMessage(detailsResult.Result));
}
var details = detailsResult.ProductDetailsList.FirstOrDefault();
var offer = details?.GetSubscriptionOfferDetails()?
.FirstOrDefault(item => item.BasePlanId == basePlanId && string.IsNullOrEmpty(item.OfferId));
if (details is null || offer is null)
{
return PlayBillingResult.Failed("Piano LME Pro non disponibile. Verifica prodotto e base plan nella Play Console.");
}
var productParams = BillingFlowParams.ProductDetailsParams.NewBuilder()
.SetProductDetails(details)
.SetOfferToken(offer.OfferToken)
.Build();
var flow = BillingFlowParams.NewBuilder()
.SetProductDetailsParamsList([productParams])
.Build();
var activity = Platform.CurrentActivity
?? throw new InvalidOperationException("Activity Android non disponibile.");
var completion = new TaskCompletionSource<PlayBillingResult>(TaskCreationOptions.RunContinuationsAsynchronously);
_purchaseCompletion = completion;
using var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
var launchResult = _client.LaunchBillingFlow(activity, flow);
if (launchResult.ResponseCode != BillingResponseCode.Ok)
{
_purchaseCompletion = null;
return PlayBillingResult.Failed(ToMessage(launchResult));
}
return await completion.Task;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception error)
{
_purchaseCompletion = null;
return PlayBillingResult.Failed($"Acquisto non avviato: {error.GetBaseException().Message}");
}
}
public async void OnPurchasesUpdated(BillingResult billingResult, IList<Purchase>? purchases)
{
var completion = Interlocked.Exchange(ref _purchaseCompletion, null);
if (completion is null)
{
return;
}
if (billingResult.ResponseCode == BillingResponseCode.UserCancelled)
{
completion.TrySetResult(PlayBillingResult.Cancelled("Acquisto annullato."));
return;
}
if (billingResult.ResponseCode != BillingResponseCode.Ok)
{
completion.TrySetResult(PlayBillingResult.Failed(ToMessage(billingResult)));
return;
}
var purchase = purchases?.FirstOrDefault(IsPurchasedProSubscription);
if (purchase is null)
{
completion.TrySetResult(PlayBillingResult.Failed("Acquisto non ancora completato."));
return;
}
var acknowledgement = await AcknowledgeIfNeededAsync(purchase);
completion.TrySetResult(acknowledgement ?? PlayBillingResult.Active("Abbonamento LME Pro attivato."));
}
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
if (_client.IsReady)
{
return;
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_client.IsReady)
{
return;
}
var result = await _client.StartConnectionAsync();
if (result.ResponseCode != BillingResponseCode.Ok)
{
throw new InvalidOperationException(ToMessage(result));
}
}
finally
{
_connectionLock.Release();
}
}
private async Task<PlayBillingResult?> AcknowledgeIfNeededAsync(Purchase purchase)
{
if (purchase.IsAcknowledged)
{
return null;
}
var parameters = AcknowledgePurchaseParams.NewBuilder()
.SetPurchaseToken(purchase.PurchaseToken)
.Build();
var result = await _client.AcknowledgePurchaseAsync(parameters);
return result.ResponseCode == BillingResponseCode.Ok
? null
: PlayBillingResult.Failed($"Acquisto trovato ma non confermato: {ToMessage(result)}");
}
private static bool IsPurchasedProSubscription(Purchase purchase) =>
purchase.PurchaseState == PurchaseState.Purchased
&& purchase.Products.Contains(EntitlementService.ProductId);
private static string ToMessage(BillingResult result) =>
string.IsNullOrWhiteSpace(result.DebugMessage)
? $"Google Play Billing: {result.ResponseCode}."
: result.DebugMessage;
}
+95 -9
View File
@@ -2,7 +2,93 @@
App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS. App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS.
## Funzioni gia implementate ## Versione 0.4.7
## Preparazione versione Pro Play Store
- La copia Pro usa l'identificativo Android `it.liviomerola.lmemappecatastali.pro`, titolo `LME Mappe Catastali Pro`, versione iniziale `1.0.0` e Android App Bundle in Release.
- La separazione Base/Pro e centralizzata in `Services/EntitlementService.cs`; le funzioni Pro principali sono archivio progetti, export, sync WebDAV, offline, strumenti avanzati e traccia GPS.
- L'accesso Pro puo derivare da Google Play oppure da un codice manuale cifrato e firmato, con ritorno automatico a Base alla scadenza. Il gestore e le istruzioni sono in `tools/LmeLicenseManager` e `MANUAL_LICENSES.md`.
- Lingua e sistema di misura sono centralizzati in `Services/AppLocalizationService.cs` e propagati alla mappa JavaScript, incluse distanze, superfici e Leaflet Draw.
- La checklist Play Store, Billing, firma e privacy e in `PLAY_STORE_PRO_CHECKLIST.md`.
- Il progetto usa .NET 10, target Android API 36 e Google Play Billing 9.1.
- 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`.
- L'elenco revisioni viene aggiornato dopo salvataggi manuali e automatici, rinomina, importazione e selezione del progetto.
- Il pannello Progetti mostra quante revisioni sono disponibili e spiega quando un progetto appena creato non possiede ancora versioni precedenti.
## Versione 0.4.6
- `Mostra cornice/layer del foglio` carica dal WFS ufficiale AdE il vero confine `CP:CadastralZoning` di ciascun foglio delle particelle.
- Le particelle appartenenti a fogli diversi producono cornici distinte, senza piu creare un unico rettangolo cumulativo tra aree lontane.
- Le cornici gia presenti nel progetto o trasferite in un altro gruppo vengono riconosciute e non vengono scaricate o duplicate.
- Ogni foglio e salvato come oggetto autonomo e puo essere rinominato, bloccato, rimosso o spostato in un altro gruppo dal pannello Layer.
- I vecchi riquadri cumulativi generati automaticamente vengono rimossi durante il caricamento e sostituiti dai fogli ufficiali.
## Versione 0.4.5
- Corretto il layout del pannello Layer sui telefoni e sui foldable: checkbox, lucchetto, ordinamento e freccia di espansione non si sovrappongono e restano allineati.
- La tabella oggetti include un comando dedicato per modificare i metadati di ciascun elemento.
- L'editor metadati permette di copiare i campi da un altro oggetto, scegliendo se importare anche i valori oppure soltanto i nomi.
- Il pannello Progetti consente di rinominare un progetto duplicato o esistente senza perdere contenuti, identita e revisioni.
- La barra Leaflet include una guida completa con istruzioni ed esempi concreti per disegno, modifica, eliminazione, riparazione, fori e confronto confini.
## Versione 0.4.4
- I comandi `Rinomina`, `Sposta nel gruppo` e `Rimuovi` occupano una riga completa e restano visibili anche nei pannelli Layer molto stretti.
- `Sposta nel gruppo` e disponibile per padri e figli di disegni, dati importati, cornici, fogli importati e particelle evidenziate dalla ricerca catastale.
- Editor distinti per linea e riempimento su gruppi e singoli oggetti, con contagocce dove supportato e valori modificabili HEX, RGB e HSL.
- Copia HEX per riutilizzare esattamente un colore su altri oggetti; stili conservati nei progetti e negli export.
- Rimossi dai menu i riferimenti al colore rosso fisso delle particelle catastali.
- Nei progetti in sola lettura anche il trasferimento tra gruppi viene correttamente disabilitato.
## Versione 0.4.3
- `Mostra particella sulla mappa` riattiva sempre il layer dei confini rossi e lo porta in primo piano.
- Una particella gia evidenziata o temporaneamente nascosta viene recuperata senza creare duplicati.
- Deduplicazione estesa alle particelle presenti in progetti e file GeoJSON, KML e KMZ, tramite riferimenti catastali o confronto geometrico.
- Ricerca WFS ampliata per rendere piu affidabile il recupero dei confini di particelle estese o irregolari.
- Guida rapida collassabile in fondo a Layer e a ogni pannello operativo, con istruzioni specifiche per le relative funzioni.
## Versione 0.4.2
- Nuovo comando `Sposta nel gruppo` visibile accanto a Rinomina e Rimuovi per padri, sottogruppi e singoli oggetti.
- Scelta della categoria e del sottogruppo di destinazione, con possibilita di creare un nuovo gruppo.
- I disegni possono essere organizzati in sottogruppi come i dati importati.
- `Sposta geometria` resta separato e continua a traslare gli oggetti in metri verso nord, sud, est o ovest.
## Versione 0.4.1
- Pannelli operativi allineati a destra, con ricerca lasciata nella posizione precedente.
- Espansione della gerarchia Layer esclusivamente tramite freccia accanto alla maniglia di trascinamento.
- Colori coerenti tra gruppo padre e figli, intensita progressiva per livello e conteggio figli in grassetto.
- Spostamento dei gruppi tra `Disegni e POI` e `Progetti e dati importati`.
- Rinomine persistenti nei progetti e negli export GeoJSON, KML e KMZ.
- Gli oggetti e i gruppi rimossi dal progetto non vengono piu inclusi nei salvataggi e negli export.
- Editor interni per rinomina, attributi multilinea, coordinate, spostamento e buffer.
- Selezione multipla e blocco disponibili anche nella tabella oggetti e particelle.
- Evidenziazione nel Layer dell'oggetto selezionato sulla mappa e centratura automatica opzionale, disattivata per impostazione predefinita.
- Strumenti controllo/riparazione, foro e confronto nella barra Leaflet, con risultati visibili in una finestra dedicata.
- Comandi del pannello Progetti disposti verticalmente senza scorrimento orizzontale.
## Versione 0.4.0
La mappa ora occupa tutta la schermata: il pannello `Layer` resta subito accessibile, mentre ricerca, catasto, dati, progetti, esportazione, strumenti avanzati e impostazioni sono raccolti nel menu in alto.
Tra le funzioni principali della versione 0.4.0:
- rinomina, ordinamento, selezione, blocco e rimozione reversibile di singoli oggetti e gruppi;
- evidenziazione coordinata dei gruppi e gerarchia visiva tra padri, figli e livelli annidati;
- annulla/ripristina, cestino, storico modifiche, autosalvataggio e backup programmati;
- modifica o eliminazione del solo disegno selezionato oppure di tutti i disegni;
- tabella oggetti, ricerca, attributi personalizzati, etichette, note e foto;
- strumenti geometrici per buffer, unione, divisione, fori, confronto e controllo/riparazione;
- importazione controllata di GeoJSON, KML e KMZ con analisi, pulizia e deduplicazione;
- cache cartografica offline, sincronizzazione WebDAV e condivisione di progetti modificabili o in sola lettura;
- controllo aggiornamenti da Gitea con versione installata, versione disponibile, note e download APK;
- esportazione PDF con schema vettoriale, freccia nord, scala grafica e riferimenti catastali.
## Funzioni implementate
- Mappa interattiva Leaflet con sfondi Esri, OpenStreetMap, OpenTopoMap, CARTO Voyager, Google Maps e Azure Maps configurabili tramite chiave. - Mappa interattiva Leaflet con sfondi Esri, OpenStreetMap, OpenTopoMap, CARTO Voyager, Google Maps e Azure Maps configurabili tramite chiave.
- Layer WMS ufficiale Agenzia delle Entrate: particelle, numeri particella, fogli, fabbricati, strade e acque. - Layer WMS ufficiale Agenzia delle Entrate: particelle, numeri particella, fogli, fabbricati, strade e acque.
@@ -14,11 +100,11 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
- Migrazione automatica dei vecchi progetti: i GroundOverlay `DOC_...` salvati in precedenza insieme alle particelle vengono riconosciuti, separati e nascosti in tempo reale dal relativo check. - Migrazione automatica dei vecchi progetti: i GroundOverlay `DOC_...` salvati in precedenza insieme alle particelle vengono riconosciuti, separati e nascosti in tempo reale dal relativo check.
- Inquadramento e pulizia dei layer importati. - Inquadramento e pulizia dei layer importati.
- Disegno di marker, linee, poligoni e rettangoli. - Disegno di marker, linee, poligoni e rettangoli.
- Area, Distanza e Cerchio disponibili nella barra Leaflet laterale, nascosta all'avvio e controllata dal pulsante nel tab Dati insieme agli altri strumenti. - Area, Distanza e Cerchio disponibili nella barra Leaflet laterale, nascosta all'avvio e controllata dalla sezione `Dati e disegni` insieme agli altri strumenti.
- Misure permanenti attivabili anche sui layer importati da KML, KMZ e GeoJSON. - Misure permanenti attivabili anche sui layer importati da KML, KMZ e GeoJSON.
- Toolbar di disegno completa di icone, modifica, eliminazione singola e pulizia di tutti i disegni/POI. - Toolbar di disegno completa di icone, modifica, eliminazione singola e pulizia di tutti i disegni/POI.
- Archivio multiprogetto nella cartella privata dell'app, con nome, selezione da elenco, sovrascrittura ed eliminazione. - Archivio multiprogetto nella cartella privata dell'app, con nome, selezione da elenco, sovrascrittura ed eliminazione.
- Tab distinti `Progetti` e `Salvataggio`: il primo gestisce archivio e opzioni live di visualizzazione, il secondo esporta selettivamente l'intera mappa in GeoJSON, KML o KMZ. - Sezioni `Progetti` ed `Esporta` nel menu: la prima gestisce archivio e opzioni live di visualizzazione, la seconda esporta selettivamente l'intera mappa in GeoJSON, KML o KMZ.
- Ogni progetto salva file GIS, POI, disegni, traccia, inquadratura e tutte le opzioni di visualizzazione: sfondo, layer catastali, trasparenza, misure, foglio importato, barra strumenti e preferenze GPS. - Ogni progetto salva file GIS, POI, disegni, traccia, inquadratura e tutte le opzioni di visualizzazione: sfondo, layer catastali, trasparenza, misure, foglio importato, barra strumenti e preferenze GPS.
- GPS nativo MAUI ad alta precisione con permessi Android/iOS. - GPS nativo MAUI ad alta precisione con permessi Android/iOS.
- Centratura automatica una tantum all'apertura: prima tramite posizione approssimativa della connessione Internet, poi tramite singola lettura GPS se la rete non restituisce una posizione; nessun punto o cerchio viene mostrato e il tracking continuo non viene avviato. - Centratura automatica una tantum all'apertura: prima tramite posizione approssimativa della connessione Internet, poi tramite singola lettura GPS se la rete non restituisce una posizione; nessun punto o cerchio viene mostrato e il tracking continuo non viene avviato.
@@ -26,13 +112,13 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
- Marker posizione, cerchio accuratezza in metri, tracciamento percorso. - Marker posizione, cerchio accuratezza in metri, tracciamento percorso.
- Controllo se la posizione GPS cade dentro un poligono importato o disegnato. - Controllo se la posizione GPS cade dentro un poligono importato o disegnato.
- Interrogazione WFS ufficiale Agenzia delle Entrate attorno alla posizione GPS per evidenziare la particella catastale corrente quando il servizio restituisce la geometria. - Interrogazione WFS ufficiale Agenzia delle Entrate attorno alla posizione GPS per evidenziare la particella catastale corrente quando il servizio restituisce la geometria.
- Mappa a tutto schermo con tab separati Catasto, Dati, Salvataggio e Impostazioni, adattabile agli schermi pieghevoli. - Mappa a tutto schermo con menu compatto e pannelli adattabili a telefoni, tablet e schermi pieghevoli.
- Ingrandimento oltre lo zoom satellitare nativo senza tessere "Map data not yet available". - Ingrandimento oltre lo zoom satellitare nativo senza tessere "Map data not yet available".
- Tendine concatenate Regione, Comune, Foglio e Particella. - Tendine concatenate Regione, Comune, Foglio e Particella.
- Ricerca di localita e indirizzi eseguita nativamente con fallback Esri e OpenStreetMap, senza richieste CORS dalla WebView. - Ricerca di localita e indirizzi eseguita nativamente con fallback Esri e OpenStreetMap, senza richieste CORS dalla WebView.
- Catalogo dei Comuni incluso nell'APK e cataloghi regionali OnData scaricati in cache solo quando servono. - Catalogo dei Comuni incluso nell'APK e cataloghi regionali OnData scaricati in cache solo quando servono.
- Ricerca catastale eseguita dal codice .NET nativo, senza DuckDB WebAssembly nella WebView Android. - Ricerca catastale eseguita dal codice .NET nativo, senza DuckDB WebAssembly nella WebView Android.
- La registrazione della traccia GPS e' disattivata all'avvio; la toolbar di disegno completa puo' essere mostrata o nascosta dal tab Dati. - La registrazione della traccia GPS e' disattivata all'avvio; la toolbar di disegno completa puo' essere mostrata o nascosta dalla sezione `Dati e disegni`.
- Pannello `Layer` accessibile direttamente dalla mappa, con checkbox sincronizzati per confini, numeri particella, fogli, fabbricati, strade e acque. - Pannello `Layer` accessibile direttamente dalla mappa, con checkbox sincronizzati per confini, numeri particella, fogli, fabbricati, strade e acque.
- Le particelle cercate possono essere aggiunte una dopo l'altra, mantenendo contemporaneamente sulla mappa tutti i confini selezionati. - Le particelle cercate possono essere aggiunte una dopo l'altra, mantenendo contemporaneamente sulla mappa tutti i confini selezionati.
- Comandi separati per rimuovere le particelle selezionate o pulire importazioni, disegni, POI e traccia GPS. - Comandi separati per rimuovere le particelle selezionate o pulire importazioni, disegni, POI e traccia GPS.
@@ -81,13 +167,13 @@ Per Android serve Android SDK installato e configurato in Visual Studio.
## Build da terminale ## Build da terminale
```powershell ```powershell
dotnet build -f net9.0-windows10.0.19041.0 dotnet build -f net10.0-windows10.0.19041.0
``` ```
Per Android: Per Android:
```powershell ```powershell
dotnet build -f net9.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Local\Android\Sdk dotnet build -f net10.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Local\Android\Sdk
``` ```
## APK Release generato ## APK Release generato
@@ -95,11 +181,11 @@ dotnet build -f net9.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Loc
La pubblicazione Android genera: La pubblicazione Android genera:
```text ```text
C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Release\net9.0-android\publish\com.companyname.lmemappecatastaliapp-Signed.apk C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliPRO\bin\Release\net10.0-android\publish\it.liviomerola.lmemappecatastali.pro-Signed.apk
``` ```
Per installarlo con telefono collegato e debug USB attivo: Per installarlo con telefono collegato e debug USB attivo:
```powershell ```powershell
C:\Users\livio\AppData\Local\Android\Sdk\platform-tools\adb.exe install -r C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Release\net9.0-android\publish\com.companyname.lmemappecatastaliapp-Signed.apk C:\Users\livio\AppData\Local\Android\Sdk\platform-tools\adb.exe install -r C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliPRO\artifacts\android-test\LME-Mappe-Catastali-Pro-1.0.0-test.apk
``` ```
+4 -4
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg"> <svg width="456" height="456" viewBox="0 0 456 456" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" /> <rect width="456" height="456" fill="#075E54" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 187 B

+13 -8
View File
@@ -1,8 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <svg width="456" height="456" viewBox="0 0 456 456" xmlns="http://www.w3.org/2000/svg">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <g stroke-linecap="round" stroke-linejoin="round">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> <path d="M105 150L183 119L273 151L351 120V307L273 338L183 306L105 337Z"
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> fill="#F7FAF5" stroke="#E1ECE5" stroke-width="10" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> <path d="M183 119V306M273 151V338" fill="none" stroke="#83B6A5" stroke-width="10" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> <path d="M125 190L162 175M124 274L160 260M293 196L330 181M295 282L330 268"
</svg> fill="none" stroke="#A9CDBE" stroke-width="9" />
<path d="M228 104C188 104 157 135 157 175C157 225 228 294 228 294C228 294 299 225 299 175C299 135 268 104 228 104Z"
fill="#F97316" stroke="#FFFFFF" stroke-width="12" />
<circle cx="228" cy="175" r="24" fill="#FFFFFF" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 834 B

+192
View File
@@ -0,0 +1,192 @@
using System.Globalization;
namespace LMEMappeCatastaliApp.Services;
public sealed class AppLocalizationService
{
public const string LanguagePreferenceKey = "app_language";
public const string MeasurementPreferenceKey = "measurement_system";
private static readonly HashSet<string> SupportedLanguages = new(StringComparer.OrdinalIgnoreCase)
{
"auto",
"it",
"en"
};
private static readonly HashSet<string> SupportedMeasurementSystems = new(StringComparer.OrdinalIgnoreCase)
{
"auto",
"metric",
"imperial"
};
private static readonly Dictionary<string, Dictionary<string, string>> Strings = new(StringComparer.OrdinalIgnoreCase)
{
["it"] = new(StringComparer.Ordinal)
{
["settings.localization.title"] = "Lingua e misure",
["settings.language"] = "Lingua",
["settings.language.auto"] = "Automatica",
["settings.language.it"] = "Italiano",
["settings.language.en"] = "English",
["settings.measurements"] = "Sistema di misura",
["settings.measurements.auto"] = "Automatico in base all'area",
["settings.measurements.metric"] = "Metrico",
["settings.measurements.imperial"] = "Imperiale",
["settings.pro.title"] = "Abbonamento Pro",
["settings.pro.status"] = "Stato",
["settings.pro.active"] = "Pro attivo",
["settings.pro.base"] = "Base gratuita",
["settings.pro.offline"] = "Offline: le funzioni Pro restano disponibili durante il periodo di tolleranza senza cancellare i dati.",
["settings.pro.restore"] = "Ripristina acquisti",
["settings.pro.manage"] = "Gestisci abbonamento",
["settings.pro.monthly"] = "Pro mensile",
["settings.pro.yearly"] = "Pro annuale",
["settings.pro.note"] = "Il prodotto LME Pro si configura nella Play Console con piano mensile e annuale.",
["settings.pro.manual.code"] = "Codice licenza manuale",
["settings.pro.manual.help"] = "Incolla il codice cifrato ricevuto. Nome, email e scadenza vengono letti solo dopo la verifica crittografica.",
["settings.pro.manual.activate"] = "Attiva codice",
["settings.pro.manual.remove"] = "Rimuovi licenza",
["settings.pro.manual.expires"] = "Scadenza",
["settings.pro.manual.valid"] = "Licenza manuale valida",
["settings.pro.manual.expired"] = "Licenza manuale scaduta: funzionalita Base attive",
["settings.pro.manual.activated"] = "Licenza manuale attivata.",
["settings.pro.manual.invalid"] = "Codice licenza non valido.",
["settings.pro.manual.removed"] = "Licenza manuale rimossa.",
["menu.quickHelp"] = "Guida rapida",
["menu.map"] = "Mappa",
["menu.search"] = "Cerca localita",
["menu.cadastre"] = "Ricerca catastale",
["menu.data"] = "Dati e disegni",
["menu.projects"] = "Progetti",
["menu.export"] = "Esporta",
["menu.tools"] = "Strumenti avanzati",
["menu.settings"] = "Impostazioni",
["panel.settings.title"] = "Impostazioni",
["panel.settings.eyebrow"] = "Mappa, GPS, Pro e servizi ufficiali",
["measure.area"] = "Misura area",
["measure.distance"] = "Misura distanza",
["measure.circle"] = "Misura cerchio",
["measure.rectangle"] = "Disegna rettangolo",
["measure.poi"] = "Aggiungi POI"
},
["en"] = new(StringComparer.Ordinal)
{
["settings.localization.title"] = "Language and units",
["settings.language"] = "Language",
["settings.language.auto"] = "Automatic",
["settings.language.it"] = "Italiano",
["settings.language.en"] = "English",
["settings.measurements"] = "Measurement system",
["settings.measurements.auto"] = "Automatic by region",
["settings.measurements.metric"] = "Metric",
["settings.measurements.imperial"] = "Imperial",
["settings.pro.title"] = "Pro subscription",
["settings.pro.status"] = "Status",
["settings.pro.active"] = "Pro active",
["settings.pro.base"] = "Free base plan",
["settings.pro.offline"] = "Offline: Pro features remain available during the grace period without deleting user data.",
["settings.pro.restore"] = "Restore purchases",
["settings.pro.manage"] = "Manage subscription",
["settings.pro.monthly"] = "Monthly Pro",
["settings.pro.yearly"] = "Yearly Pro",
["settings.pro.note"] = "Create the LME Pro product in Play Console with monthly and yearly base plans.",
["settings.pro.manual.code"] = "Manual license code",
["settings.pro.manual.help"] = "Paste the encrypted code you received. Name, email and expiry are read only after cryptographic verification.",
["settings.pro.manual.activate"] = "Activate code",
["settings.pro.manual.remove"] = "Remove license",
["settings.pro.manual.expires"] = "Expires",
["settings.pro.manual.valid"] = "Manual license valid",
["settings.pro.manual.expired"] = "Manual license expired: Base features active",
["settings.pro.manual.activated"] = "Manual license activated.",
["settings.pro.manual.invalid"] = "Invalid license code.",
["settings.pro.manual.removed"] = "Manual license removed.",
["menu.quickHelp"] = "Quick help",
["menu.map"] = "Map",
["menu.search"] = "Search place",
["menu.cadastre"] = "Cadastral search",
["menu.data"] = "Data and drawings",
["menu.projects"] = "Projects",
["menu.export"] = "Export",
["menu.tools"] = "Advanced tools",
["menu.settings"] = "Settings",
["panel.settings.title"] = "Settings",
["panel.settings.eyebrow"] = "Map, GPS, Pro and official services",
["measure.area"] = "Measure area",
["measure.distance"] = "Measure distance",
["measure.circle"] = "Measure circle",
["measure.rectangle"] = "Draw rectangle",
["measure.poi"] = "Add POI"
}
};
public string LanguagePreference { get; private set; } = "auto";
public string MeasurementPreference { get; private set; } = "auto";
public CultureInfo Culture => new(ResolveLanguage());
public string MeasurementSystem => MeasurementPreference == "auto"
? ResolveAutomaticMeasurementSystem()
: MeasurementPreference;
public void Load()
{
LanguagePreference = NormalizeLanguage(Preferences.Default.Get(LanguagePreferenceKey, "auto"));
MeasurementPreference = NormalizeMeasurementSystem(Preferences.Default.Get(MeasurementPreferenceKey, "auto"));
}
public void SetLanguage(string? language)
{
LanguagePreference = NormalizeLanguage(language);
Preferences.Default.Set(LanguagePreferenceKey, LanguagePreference);
ApplyCulture();
}
public void SetMeasurementSystem(string? measurementSystem)
{
MeasurementPreference = NormalizeMeasurementSystem(measurementSystem);
Preferences.Default.Set(MeasurementPreferenceKey, MeasurementPreference);
}
public void ApplyCulture()
{
var culture = Culture;
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
}
public string T(string key)
{
var language = ResolveLanguage();
if (Strings.TryGetValue(language, out var table) && table.TryGetValue(key, out var value))
{
return value;
}
return Strings["it"].TryGetValue(key, out var fallback) ? fallback : key;
}
private string ResolveLanguage()
{
if (LanguagePreference != "auto")
{
return LanguagePreference;
}
var twoLetter = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
return Strings.ContainsKey(twoLetter) ? twoLetter : "en";
}
private static string ResolveAutomaticMeasurementSystem()
{
var region = RegionInfo.CurrentRegion.TwoLetterISORegionName;
return region is "US" or "LR" or "MM" ? "imperial" : "metric";
}
private static string NormalizeLanguage(string? language) =>
!string.IsNullOrWhiteSpace(language) && SupportedLanguages.Contains(language) ? language.ToLowerInvariant() : "auto";
private static string NormalizeMeasurementSystem(string? measurementSystem) =>
!string.IsNullOrWhiteSpace(measurementSystem) && SupportedMeasurementSystems.Contains(measurementSystem) ? measurementSystem.ToLowerInvariant() : "auto";
}
+44
View File
@@ -0,0 +1,44 @@
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json.Serialization;
namespace LMEMappeCatastaliApp.Services;
public sealed class AppUpdateService
{
private const string LatestReleaseUrl = "https://gitea.lmcloud.it/api/v1/repos/livio/LMEMappeCatastaliApp/releases/latest";
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) };
public static string CurrentVersion =>
typeof(AppUpdateService).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion.Split('+')[0]
?? AppInfo.Current.VersionString;
public async Task<AppUpdateResult> CheckAsync(CancellationToken cancellationToken = default)
{
var release = await _httpClient.GetFromJsonAsync<GiteaRelease>(LatestReleaseUrl, cancellationToken)
?? throw new InvalidDataException("Risposta aggiornamenti non valida.");
var latestText = (release.TagName ?? string.Empty).TrimStart('v', 'V');
_ = Version.TryParse(CurrentVersion, out var current);
_ = Version.TryParse(latestText, out var latest);
var apk = release.Assets?.FirstOrDefault(item => item.Name?.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) == true);
return new(latest is not null && current is not null && latest > current, latestText, release.HtmlUrl ?? string.Empty, apk?.BrowserDownloadUrl ?? release.HtmlUrl ?? string.Empty, release.Body ?? string.Empty);
}
private sealed class GiteaRelease
{
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
[JsonPropertyName("html_url")] public string? HtmlUrl { get; set; }
[JsonPropertyName("assets")] public GiteaAsset[]? Assets { get; set; }
[JsonPropertyName("body")] public string? Body { get; set; }
}
private sealed class GiteaAsset
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
}
}
public sealed record AppUpdateResult(bool IsAvailable, string Version, string ReleaseUrl, string DownloadUrl, string ReleaseNotes);
+1 -1
View File
@@ -13,7 +13,7 @@ public sealed class AzureMapsService
public AzureMapsService() public AzureMapsService()
{ {
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.16"); _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.22");
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
} }
+54
View File
@@ -0,0 +1,54 @@
using System.Net.Http.Headers;
using System.Text;
namespace LMEMappeCatastaliApp.Services;
public sealed class CloudSyncService
{
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromMinutes(3) };
public async Task UploadAsync(string endpoint, string userName, string password, string localPath, CancellationToken cancellationToken = default)
{
var target = BuildTarget(endpoint);
using var request = new HttpRequestMessage(HttpMethod.Put, target);
ApplyCredentials(request, userName, password);
request.Content = new StreamContent(File.OpenRead(localPath));
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
}
public async Task<string> DownloadAsync(string endpoint, string userName, string password, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, BuildTarget(endpoint));
ApplyCredentials(request, userName, password);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var directory = Path.Combine(FileSystem.CacheDirectory, "CloudSync");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, $"LME-cloud-{DateTime.Now:yyyyMMdd-HHmmss}.lmebackup.zip");
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var output = File.Create(path);
await input.CopyToAsync(output, cancellationToken);
return path;
}
private static Uri BuildTarget(string endpoint)
{
if (!Uri.TryCreate(endpoint?.Trim(), UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
{
throw new ArgumentException("Inserisci un indirizzo WebDAV http o https valido.");
}
return uri.AbsolutePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
? uri
: new Uri($"{uri.ToString().TrimEnd('/')}/LMEMappeCatastali-sync.lmebackup.zip");
}
private static void ApplyCredentials(HttpRequestMessage request, string userName, string password)
{
if (string.IsNullOrWhiteSpace(userName)) return;
var value = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", value);
}
}
+152
View File
@@ -0,0 +1,152 @@
namespace LMEMappeCatastaliApp.Services;
public sealed class EntitlementService(ManualLicenseService manualLicenses, IPlayBillingService playBilling)
{
public const string ProductId = "lme_pro";
public const string MonthlyBasePlanId = "monthly";
public const string YearlyBasePlanId = "yearly";
private const string GoogleProActivePreference = "entitlement_google_pro_active";
private const string GoogleExpiresAtPreference = "entitlement_google_pro_expires_at";
private const string GoogleLastVerifiedAtPreference = "entitlement_google_pro_last_verified_at";
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(7);
private static readonly HashSet<string> ProFeatures = new(StringComparer.Ordinal)
{
AppFeatures.ProjectArchive,
AppFeatures.Export,
AppFeatures.CloudSync,
AppFeatures.OfflineMaps,
AppFeatures.AdvancedGeometry,
AppFeatures.GpsTrail
};
public bool IsGoogleProActive { get; private set; }
public DateTimeOffset? GoogleExpiresAt { get; private set; }
public DateTimeOffset? GoogleLastVerifiedAt { get; private set; }
public ManualLicenseInfo? ManualLicense => manualLicenses.Current;
public string? ManualLicenseError => manualLicenses.LastError;
public bool HasGoogleProAccess
{
get
{
var now = DateTimeOffset.UtcNow;
if (!IsGoogleProActive || GoogleExpiresAt is { } expiresAt && expiresAt <= now)
{
return false;
}
return GoogleLastVerifiedAt is null
|| now - GoogleLastVerifiedAt <= OfflineGracePeriod;
}
}
public bool HasManualProAccess => manualLicenses.HasActiveLicense;
public bool HasProAccess => HasGoogleProAccess || HasManualProAccess;
public string PlanLabel => HasProAccess
? HasGoogleProAccess && HasManualProAccess ? "LME Pro (Google + manuale)"
: HasGoogleProAccess ? "LME Pro (Google Play)"
: "LME Pro (licenza manuale)"
: "Base";
public bool RequiresPro(string feature) => ProFeatures.Contains(feature);
public bool CanUse(string feature) => !RequiresPro(feature) || HasProAccess;
public async Task LoadAsync()
{
IsGoogleProActive = Preferences.Default.Get(GoogleProActivePreference, false);
GoogleExpiresAt = ReadDate(GoogleExpiresAtPreference);
GoogleLastVerifiedAt = ReadDate(GoogleLastVerifiedAtPreference);
await manualLicenses.LoadAsync();
}
public void ApplyVerifiedGooglePro(DateTimeOffset? expiresAt)
{
IsGoogleProActive = true;
GoogleExpiresAt = expiresAt;
GoogleLastVerifiedAt = DateTimeOffset.UtcNow;
SaveGoogleState();
}
public void ApplyGoogleBase()
{
IsGoogleProActive = false;
GoogleExpiresAt = null;
GoogleLastVerifiedAt = DateTimeOffset.UtcNow;
SaveGoogleState();
}
public Task<ManualLicenseActivationResult> ActivateManualLicenseAsync(string? token) =>
manualLicenses.ActivateAsync(token);
public Task RemoveManualLicenseAsync() => manualLicenses.RemoveAsync();
public async Task<PlayBillingResult> RestorePurchasesAsync()
{
var result = await playBilling.RestoreAsync();
ApplyPlayBillingResult(result);
return result;
}
public async Task<PlayBillingResult> PurchaseGoogleProAsync(string basePlanId)
{
var result = await playBilling.PurchaseAsync(basePlanId);
ApplyPlayBillingResult(result);
return result;
}
private void ApplyPlayBillingResult(PlayBillingResult result)
{
if (!result.Completed)
{
return;
}
if (result.HasActiveSubscription)
{
ApplyVerifiedGooglePro(null);
}
else
{
ApplyGoogleBase();
}
}
private void SaveGoogleState()
{
Preferences.Default.Set(GoogleProActivePreference, IsGoogleProActive);
WriteDate(GoogleExpiresAtPreference, GoogleExpiresAt);
WriteDate(GoogleLastVerifiedAtPreference, GoogleLastVerifiedAt);
}
private static DateTimeOffset? ReadDate(string key)
{
var value = Preferences.Default.Get(key, string.Empty);
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
private static void WriteDate(string key, DateTimeOffset? value)
{
if (value is null)
{
Preferences.Default.Remove(key);
return;
}
Preferences.Default.Set(key, value.Value.ToString("O"));
}
}
public static class AppFeatures
{
public const string CadastreMap = "cadastre-map";
public const string BasicSearch = "basic-search";
public const string DrawingMeasurements = "drawing-measurements";
public const string ProjectArchive = "project-archive";
public const string Export = "export";
public const string CloudSync = "cloud-sync";
public const string OfflineMaps = "offline-maps";
public const string AdvancedGeometry = "advanced-geometry";
public const string GpsTrail = "gps-trail";
}
+24
View File
@@ -0,0 +1,24 @@
namespace LMEMappeCatastaliApp.Services;
public interface IPlayBillingService
{
Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default);
Task<PlayBillingResult> PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default);
}
public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscription, bool UserCancelled, string Message)
{
public static PlayBillingResult Active(string message) => new(true, true, false, message);
public static PlayBillingResult Base(string message) => new(true, false, false, message);
public static PlayBillingResult Cancelled(string message) => new(false, false, true, message);
public static PlayBillingResult Failed(string message) => new(false, false, false, message);
}
public sealed class UnavailablePlayBillingService : IPlayBillingService
{
public Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(PlayBillingResult.Failed("Google Play Billing è disponibile solo nell'app Android installata da Google Play."));
public Task<PlayBillingResult> PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default) =>
Task.FromResult(PlayBillingResult.Failed("Google Play Billing è disponibile solo nell'app Android installata da Google Play."));
}
+226
View File
@@ -0,0 +1,226 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LMEMappeCatastaliApp.Services;
public sealed class ManualLicenseService
{
private const string SecureStorageKey = "lme_manual_license_token";
private const string LastObservedUtcPreference = "lme_license_last_observed_utc";
private const string ProductName = "lme-pro-manual";
private const string EncryptionKeyBase64 = "GiTTRSc1y4DseejPaA0NiXgy95DlgEsIe3JKdJ70sVQ=";
private const string PublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAmjh4E1fpaaonATNDipsR
1iy5cNSStD7fgB7i5xJTSZV36Td1B7hQXfZB8Z7vx9iFJpbCQMePuFiDKNbTUQFf
EKEp8lBq7SHo0LvbKNOSK9RMkqpb3ova9Er14gSCgshivf1c774VXTlt9J7uAWoE
QeQMNKJUSogoMkxEKc4Xmye65lGjJVeiuqdDCJh5o42j8DfGwqOyGX4808vSeixe
3lArRmoq9SGNO05VVunF8NKJyhiRLfropUvC+eFsiJMp3VMEYVn5FUcd8ljO67jv
pnngWh6zCZxs+h0mT0YRU9F3tWHqqsG1PE6NZnspmBdNfUfb9LxKt1RshEhUWiBx
0vvdUtg20nESOvFnieIX64pcx0LqFlDMmEWDMQjZ7KEtwXVphN1mhsryUiS6Cz3b
oKTryH4FQY9iIWNNzdzXujrV5ycE49orFEtrXRPnXBlB97Hl3Dlxi19lvPwxWXQ5
fKXrTJ2rL/WvlMpZ5YOggAmv7P7tA4B+Oi5uMiHzXEDlAgMBAAE=
-----END PUBLIC KEY-----
""";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private string? _token;
public ManualLicenseInfo? Current { get; private set; }
public string? LastError { get; private set; }
public bool HasActiveLicense => Current is { } license && license.ExpiresAtUtc > GetEffectiveUtcNow();
public async Task LoadAsync()
{
try
{
_token = await SecureStorage.Default.GetAsync(SecureStorageKey);
}
catch
{
_token = null;
}
ValidateStoredToken();
}
public async Task<ManualLicenseActivationResult> ActivateAsync(string? token)
{
var normalized = token?.Trim() ?? string.Empty;
var result = Validate(normalized);
if (!result.IsValid || result.License is null)
{
LastError = result.Error;
return result;
}
try
{
await SecureStorage.Default.SetAsync(SecureStorageKey, normalized);
}
catch
{
return ManualLicenseActivationResult.Failure("Impossibile salvare la licenza nell'archivio sicuro del dispositivo.");
}
_token = normalized;
Current = result.License;
LastError = null;
ObserveUtcNow();
return result;
}
public Task RemoveAsync()
{
SecureStorage.Default.Remove(SecureStorageKey);
_token = null;
Current = null;
LastError = null;
return Task.CompletedTask;
}
private void ValidateStoredToken()
{
if (string.IsNullOrWhiteSpace(_token))
{
Current = null;
LastError = null;
return;
}
var result = Validate(_token);
Current = result.License;
LastError = result.Error;
ObserveUtcNow();
}
private static ManualLicenseActivationResult Validate(string token)
{
try
{
var parts = token.Split('.');
if (parts.Length != 3 || parts[0] != "LME1")
{
return ManualLicenseActivationResult.Failure("Formato della licenza non riconosciuto.");
}
var envelope = Base64UrlDecode(parts[1]);
var signature = Base64UrlDecode(parts[2]);
using var rsa = RSA.Create();
rsa.ImportFromPem(PublicKeyPem);
if (!rsa.VerifyData(envelope, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
return ManualLicenseActivationResult.Failure("Firma della licenza non valida.");
}
var payloadBytes = DecryptEnvelope(envelope);
var payload = JsonSerializer.Deserialize<ManualLicensePayload>(payloadBytes, JsonOptions);
if (payload is null
|| payload.Version != 1
|| payload.Product != ProductName
|| !Guid.TryParse(payload.LicenseId, out _)
|| string.IsNullOrWhiteSpace(payload.FirstName)
|| string.IsNullOrWhiteSpace(payload.LastName)
|| string.IsNullOrWhiteSpace(payload.Email)
|| payload.ExpiresAtUtc <= payload.IssuedAtUtc)
{
return ManualLicenseActivationResult.Failure("Contenuto della licenza non valido.");
}
var license = new ManualLicenseInfo(
payload.LicenseId,
payload.FirstName.Trim(),
payload.LastName.Trim(),
payload.Email.Trim(),
payload.IssuedAtUtc,
payload.ExpiresAtUtc);
return license.ExpiresAtUtc <= GetEffectiveUtcNow()
? ManualLicenseActivationResult.Expired(license)
: ManualLicenseActivationResult.Success(license);
}
catch (Exception error) when (error is FormatException or CryptographicException or JsonException or ArgumentException)
{
return ManualLicenseActivationResult.Failure("Licenza danneggiata o non valida.");
}
}
private static byte[] DecryptEnvelope(byte[] envelope)
{
const int nonceLength = 12;
const int tagLength = 16;
if (envelope.Length <= nonceLength + tagLength)
{
throw new CryptographicException("Invalid license envelope.");
}
var nonce = envelope.AsSpan(0, nonceLength);
var tag = envelope.AsSpan(nonceLength, tagLength);
var ciphertext = envelope.AsSpan(nonceLength + tagLength);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(Convert.FromBase64String(EncryptionKeyBase64), tagLength);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
private static DateTimeOffset GetEffectiveUtcNow()
{
var now = DateTimeOffset.UtcNow;
var stored = Preferences.Default.Get(LastObservedUtcPreference, string.Empty);
var effective = DateTimeOffset.TryParse(stored, out var lastObserved) && lastObserved > now ? lastObserved : now;
if (!DateTimeOffset.TryParse(stored, out lastObserved) || effective > lastObserved)
{
Preferences.Default.Set(LastObservedUtcPreference, effective.ToString("O"));
}
return effective;
}
private static void ObserveUtcNow()
{
var now = DateTimeOffset.UtcNow;
var effective = GetEffectiveUtcNow();
Preferences.Default.Set(LastObservedUtcPreference, (effective > now ? effective : now).ToString("O"));
}
private sealed record ManualLicensePayload(
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("product")] string Product,
[property: JsonPropertyName("licenseId")] string LicenseId,
[property: JsonPropertyName("firstName")] string FirstName,
[property: JsonPropertyName("lastName")] string LastName,
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("issuedAtUtc")] DateTimeOffset IssuedAtUtc,
[property: JsonPropertyName("expiresAtUtc")] DateTimeOffset ExpiresAtUtc);
}
public sealed record ManualLicenseInfo(
string LicenseId,
string FirstName,
string LastName,
string Email,
DateTimeOffset IssuedAtUtc,
DateTimeOffset ExpiresAtUtc)
{
public string HolderName => $"{FirstName} {LastName}".Trim();
}
public sealed record ManualLicenseActivationResult(bool IsValid, bool IsExpired, ManualLicenseInfo? License, string? Error)
{
public static ManualLicenseActivationResult Success(ManualLicenseInfo license) => new(true, false, license, null);
public static ManualLicenseActivationResult Expired(ManualLicenseInfo license) => new(false, true, license, "La licenza è scaduta.");
public static ManualLicenseActivationResult Failure(string error) => new(false, false, null, error);
}
+246 -2
View File
@@ -35,6 +35,8 @@ public sealed class MapExportService
{ {
"kml" => "kml", "kml" => "kml",
"kmz" => "kmz", "kmz" => "kmz",
"gpx" => "gpx",
"pdf" => "pdf",
_ => "geojson" _ => "geojson"
}; };
var baseName = NormalizeFileName(requestedName); var baseName = NormalizeFileName(requestedName);
@@ -49,6 +51,18 @@ public sealed class MapExportService
return new(path, "application/geo+json", featureCount, Path.GetFileName(path)); return new(path, "application/geo+json", featureCount, Path.GetFileName(path));
} }
if (format == "gpx")
{
await File.WriteAllTextAsync(path, BuildGpx(features, baseName), new UTF8Encoding(false), cancellationToken);
return new(path, "application/gpx+xml", featureCount, Path.GetFileName(path));
}
if (format == "pdf")
{
await File.WriteAllBytesAsync(path, BuildPdfReport(features, baseName), cancellationToken);
return new(path, "application/pdf", featureCount, Path.GetFileName(path));
}
var kml = BuildKml(features, baseName); var kml = BuildKml(features, baseName);
if (format == "kml") if (format == "kml")
{ {
@@ -67,6 +81,173 @@ public sealed class MapExportService
return new(path, "application/vnd.google-earth.kmz", featureCount, Path.GetFileName(path)); return new(path, "application/vnd.google-earth.kmz", featureCount, Path.GetFileName(path));
} }
private static string BuildGpx(JsonElement features, string name)
{
var root = new XElement("gpx",
new XAttribute("version", "1.1"),
new XAttribute("creator", "LME Mappe Catastali 0.4.7"),
new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"),
new XElement("metadata", new XElement("name", name)));
foreach (var feature in features.EnumerateArray())
{
if (!feature.TryGetProperty("geometry", out var geometry) || geometry.ValueKind != JsonValueKind.Object) continue;
var properties = feature.TryGetProperty("properties", out var value) ? value : default;
var featureName = FeatureName(properties, GetString(properties, "_lmeCategory") ?? "drawing");
var type = geometry.GetProperty("type").GetString();
var coordinates = geometry.GetProperty("coordinates");
if (type == "Point")
{
root.Add(GpxPoint("wpt", coordinates, featureName));
}
else
{
foreach (var line in EnumerateGpxLines(type, coordinates))
{
root.Add(new XElement("trk", new XElement("name", featureName),
new XElement("trkseg", line.Select(point => GpxPoint("trkpt", point, null)))));
}
}
}
return new XDocument(new XDeclaration("1.0", "utf-8", null), root).ToString();
}
private static XElement GpxPoint(string element, JsonElement coordinate, string? name)
{
var values = coordinate.EnumerateArray().ToArray();
var point = new XElement(element,
new XAttribute("lat", values[1].GetDouble().ToString("G17", CultureInfo.InvariantCulture)),
new XAttribute("lon", values[0].GetDouble().ToString("G17", CultureInfo.InvariantCulture)));
if (!string.IsNullOrWhiteSpace(name)) point.Add(new XElement("name", name));
return point;
}
private static IEnumerable<JsonElement[]> EnumerateGpxLines(string? type, JsonElement coordinates)
{
if (type == "LineString") yield return coordinates.EnumerateArray().ToArray();
else if (type == "Polygon")
foreach (var ring in coordinates.EnumerateArray()) yield return ring.EnumerateArray().ToArray();
else if (type == "MultiLineString")
foreach (var line in coordinates.EnumerateArray()) yield return line.EnumerateArray().ToArray();
else if (type == "MultiPolygon")
foreach (var polygon in coordinates.EnumerateArray())
foreach (var ring in polygon.EnumerateArray()) yield return ring.EnumerateArray().ToArray();
}
private static byte[] BuildPdfReport(JsonElement features, string title)
{
var paths = features.EnumerateArray()
.Where(feature => feature.TryGetProperty("geometry", out _))
.SelectMany(feature => PdfCoordinatePaths(feature.GetProperty("geometry")))
.Where(path => path.Count > 0)
.ToArray();
var points = paths.SelectMany(path => path).ToArray();
var minLon = points.Length == 0 ? 0 : points.Min(point => point.Lon);
var maxLon = points.Length == 0 ? 1 : points.Max(point => point.Lon);
var minLat = points.Length == 0 ? 0 : points.Min(point => point.Lat);
var maxLat = points.Length == 0 ? 1 : points.Max(point => point.Lat);
var lonSpan = Math.Max(0.0000001, maxLon - minLon);
var latSpan = Math.Max(0.0000001, maxLat - minLat);
const double mapLeft = 48, mapBottom = 438, mapWidth = 499, mapHeight = 330;
var scale = Math.Min(mapWidth / lonSpan, mapHeight / latSpan);
var drawing = new StringBuilder("0.08 0.44 0.37 RG 0.86 0.94 0.91 rg 1.4 w ");
foreach (var path in paths.Take(400))
{
for (var pointIndex = 0; pointIndex < path.Count; pointIndex++)
{
var x = mapLeft + (path[pointIndex].Lon - minLon) * scale;
var y = mapBottom + (path[pointIndex].Lat - minLat) * scale;
drawing.AppendFormat(CultureInfo.InvariantCulture, "{0:0.##} {1:0.##} {2} ", x, y, pointIndex == 0 ? "m" : "l");
}
drawing.Append("S ");
}
drawing.Append("0 0 0 RG 1 w 497 730 m 497 758 l S 497 758 m 491 746 l 503 746 l h f ");
var middleLatitude = (minLat + maxLat) / 2;
var widthMeters = lonSpan * 111_320 * Math.Cos(middleLatitude * Math.PI / 180);
var scaleMeters = Math.Max(1, NiceScaleDistance(widthMeters / 4));
var scalePixels = widthMeters <= 0 ? 80 : Math.Clamp(scaleMeters / widthMeters * mapWidth, 35, 150);
drawing.AppendFormat(CultureInfo.InvariantCulture, "48 422 m {0:0.##} 422 l S 48 418 m 48 426 l S {0:0.##} 418 m {0:0.##} 426 l S ", 48 + scalePixels);
var lines = new List<string>
{
title,
$"Rapporto LME Mappe Catastali - {DateTime.Now:dd/MM/yyyy HH:mm}",
$"Oggetti: {features.GetArrayLength()}",
$"Scala grafica: {scaleMeters:0} m | Nord: alto pagina",
""
};
var index = 1;
foreach (var feature in features.EnumerateArray())
{
var properties = feature.TryGetProperty("properties", out var value) ? value : default;
var category = GetString(properties, "_lmeCategory") ?? "drawing";
var geometry = feature.TryGetProperty("geometry", out var geo) ? geo.GetProperty("type").GetString() : "-";
var sheet = GetString(properties, "foglio") ?? GetString(properties, "FOGLIO");
var parcel = GetString(properties, "particella") ?? GetString(properties, "PARTICELLA") ?? GetString(properties, "LABEL");
var reference = string.Join(" ", new[] { sheet is null ? null : $"F.{sheet}", parcel is null ? null : $"P.{parcel}" }.Where(item => item is not null));
lines.Add($"{index++}. {FeatureName(properties, category)} [{category}, {geometry}] {reference}".TrimEnd());
}
var content = drawing.Append("BT /F1 12 Tf 48 810 Td ");
foreach (var line in lines.Take(21))
{
var safe = line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)")
.Select(character => character <= 255 ? character : '?').ToArray();
content.Append('(').Append(safe).Append(") Tj 0 -16 Td ");
}
content.Append("ET");
var stream = Encoding.Latin1.GetBytes(content.ToString());
var objects = new[]
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
$"<< /Length {stream.Length} >>\nstream\n{Encoding.Latin1.GetString(stream)}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
using var output = new MemoryStream();
using var writer = new StreamWriter(output, Encoding.Latin1, leaveOpen: true) { NewLine = "\n" };
writer.Write("%PDF-1.4\n"); writer.Flush();
var offsets = new List<long> { 0 };
for (var i = 0; i < objects.Length; i++)
{
offsets.Add(output.Position); writer.Write($"{i + 1} 0 obj\n{objects[i]}\nendobj\n"); writer.Flush();
}
var xref = output.Position;
writer.Write($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
foreach (var offset in offsets.Skip(1)) writer.Write($"{offset:0000000000} 00000 n \n");
writer.Write($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF");
writer.Flush();
return output.ToArray();
}
private static double NiceScaleDistance(double value)
{
if (value <= 0) return 1;
var power = Math.Pow(10, Math.Floor(Math.Log10(value)));
var normalized = value / power;
var nice = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
return nice * power;
}
private static IEnumerable<List<(double Lon, double Lat)>> PdfCoordinatePaths(JsonElement geometry)
{
if (!geometry.TryGetProperty("type", out var typeElement) || !geometry.TryGetProperty("coordinates", out var coordinates)) yield break;
var type = typeElement.GetString();
if (type == "Point") { yield return [PdfPoint(coordinates)]; yield break; }
if (type == "LineString") { yield return coordinates.EnumerateArray().Select(PdfPoint).ToList(); yield break; }
if (type == "Polygon" || type == "MultiLineString")
{
foreach (var path in coordinates.EnumerateArray()) yield return path.EnumerateArray().Select(PdfPoint).ToList();
yield break;
}
if (type == "MultiPolygon")
{
foreach (var polygon in coordinates.EnumerateArray())
foreach (var path in polygon.EnumerateArray()) yield return path.EnumerateArray().Select(PdfPoint).ToList();
}
}
private static (double Lon, double Lat) PdfPoint(JsonElement coordinate) =>
(coordinate[0].GetDouble(), coordinate[1].GetDouble());
private static string BuildKml(JsonElement features, string documentName) private static string BuildKml(JsonElement features, string documentName)
{ {
var document = new XElement(Kml + "Document", var document = new XElement(Kml + "Document",
@@ -78,6 +259,8 @@ public sealed class MapExportService
BuildStyle("drawing", "ff1679f9", "333c92fb"), BuildStyle("drawing", "ff1679f9", "333c92fb"),
BuildStyle("poi", "ffff7a00", "33ff7a00"), BuildStyle("poi", "ffff7a00", "33ff7a00"),
BuildStyle("gps-trail", "ff00a5ff", "2200a5ff")); BuildStyle("gps-trail", "ff00a5ff", "2200a5ff"));
var categoryFolders = new Dictionary<string, XElement>(StringComparer.Ordinal);
var groupFolders = new Dictionary<(string Category, string Group), XElement>();
foreach (var feature in features.EnumerateArray()) foreach (var feature in features.EnumerateArray())
{ {
@@ -95,6 +278,11 @@ public sealed class MapExportService
var placemark = new XElement(Kml + "Placemark", var placemark = new XElement(Kml + "Placemark",
new XElement(Kml + "name", FeatureName(properties, category)), new XElement(Kml + "name", FeatureName(properties, category)),
new XElement(Kml + "styleUrl", $"#{NormalizeStyleId(category)}")); new XElement(Kml + "styleUrl", $"#{NormalizeStyleId(category)}"));
var featureStyle = BuildFeatureStyle(properties);
if (featureStyle is not null)
{
placemark.Add(featureStyle);
}
var extendedData = BuildExtendedData(properties); var extendedData = BuildExtendedData(properties);
if (extendedData is not null) if (extendedData is not null)
@@ -102,7 +290,31 @@ public sealed class MapExportService
placemark.Add(extendedData); placemark.Add(extendedData);
} }
placemark.Add(BuildGeometry(geometry)); placemark.Add(BuildGeometry(geometry));
document.Add(placemark); var categoryName = GetString(properties, "_lmeCategoryName")?.Trim();
var groupName = GetString(properties, "_lmeGroupName")?.Trim();
var parent = document;
if (!string.IsNullOrWhiteSpace(categoryName))
{
if (!categoryFolders.TryGetValue(categoryName, out var categoryFolder))
{
categoryFolder = new XElement(Kml + "Folder", new XElement(Kml + "name", categoryName));
categoryFolders[categoryName] = categoryFolder;
document.Add(categoryFolder);
}
parent = categoryFolder;
}
if (!string.IsNullOrWhiteSpace(groupName))
{
var key = (categoryName ?? string.Empty, groupName);
if (!groupFolders.TryGetValue(key, out var groupFolder))
{
groupFolder = new XElement(Kml + "Folder", new XElement(Kml + "name", groupName));
groupFolders[key] = groupFolder;
parent.Add(groupFolder);
}
parent = groupFolder;
}
parent.Add(placemark);
} }
return new XDocument(new XDeclaration("1.0", "utf-8", null), new XElement(Kml + "kml", document)).ToString(); return new XDocument(new XDeclaration("1.0", "utf-8", null), new XElement(Kml + "kml", document)).ToString();
@@ -116,6 +328,37 @@ public sealed class MapExportService
new XElement(Kml + "width", 3)), new XElement(Kml + "width", 3)),
new XElement(Kml + "PolyStyle", new XElement(Kml + "color", fillColor))); new XElement(Kml + "PolyStyle", new XElement(Kml + "color", fillColor)));
private static XElement? BuildFeatureStyle(JsonElement properties)
{
var color = GetString(properties, "_lmeColor");
var fillColor = GetString(properties, "_lmeFillColor") ?? color;
if (string.IsNullOrWhiteSpace(color) && string.IsNullOrWhiteSpace(fillColor)) return null;
var weight = GetNumber(properties, "_lmeWeight", 3);
var opacity = GetNumber(properties, "_lmeOpacity", 1);
var fillOpacity = GetNumber(properties, "_lmeFillOpacity", 0.2);
return new XElement(Kml + "Style",
new XElement(Kml + "LineStyle",
new XElement(Kml + "color", ToKmlColor(color ?? "#f97316", opacity)),
new XElement(Kml + "width", weight.ToString("0.##", CultureInfo.InvariantCulture))),
new XElement(Kml + "PolyStyle",
new XElement(Kml + "color", ToKmlColor(fillColor ?? "#fb923c", fillOpacity))));
}
private static double GetNumber(JsonElement properties, string name, double fallback) =>
properties.ValueKind == JsonValueKind.Object
&& properties.TryGetProperty(name, out var value)
&& value.ValueKind == JsonValueKind.Number
? value.GetDouble()
: fallback;
private static string ToKmlColor(string html, double opacity)
{
var value = html.TrimStart('#');
if (value.Length != 6) value = "f97316";
var alpha = (int)Math.Round(Math.Clamp(opacity, 0, 1) * 255);
return $"{alpha:x2}{value[4..6]}{value[2..4]}{value[0..2]}";
}
private static XElement? BuildExtendedData(JsonElement properties) private static XElement? BuildExtendedData(JsonElement properties)
{ {
if (properties.ValueKind != JsonValueKind.Object) if (properties.ValueKind != JsonValueKind.Object)
@@ -185,7 +428,8 @@ public sealed class MapExportService
} }
private static string FeatureName(JsonElement properties, string category) => private static string FeatureName(JsonElement properties, string category) =>
GetString(properties, "name") GetString(properties, "_lmeDisplayName")
?? GetString(properties, "name")
?? GetString(properties, "Name") ?? GetString(properties, "Name")
?? GetString(properties, "LABEL") ?? GetString(properties, "LABEL")
?? GetString(properties, "NATIONALCADASTRALREFERENCE") ?? GetString(properties, "NATIONALCADASTRALREFERENCE")
+1 -1
View File
@@ -13,7 +13,7 @@ public sealed class NetworkLocationService
public NetworkLocationService() public NetworkLocationService()
{ {
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.16"); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.22");
} }
public async Task<NetworkLocationSnapshot?> GetApproximateLocationAsync( public async Task<NetworkLocationSnapshot?> GetApproximateLocationAsync(
+30 -2
View File
@@ -22,6 +22,34 @@ public sealed class OfficialCadastreService
double longitude, double longitude,
double radiusMeters, double radiusMeters,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> await GetFeaturesNearAsync(
latitude,
longitude,
radiusMeters,
"CP:CadastralParcel",
100,
cancellationToken);
public async Task<string> GetSheetsNearAsync(
double latitude,
double longitude,
double radiusMeters = 10,
CancellationToken cancellationToken = default)
=> await GetFeaturesNearAsync(
latitude,
longitude,
radiusMeters,
"CP:CadastralZoning",
20,
cancellationToken);
private async Task<string> GetFeaturesNearAsync(
double latitude,
double longitude,
double radiusMeters,
string typeName,
int count,
CancellationToken cancellationToken)
{ {
var meters = Math.Clamp(radiusMeters, 2, 30); var meters = Math.Clamp(radiusMeters, 2, 30);
var deltaLatitude = meters / 111_320d; var deltaLatitude = meters / 111_320d;
@@ -40,11 +68,11 @@ public sealed class OfficialCadastreService
["language"] = "ita", ["language"] = "ita",
["SERVICE"] = "WFS", ["SERVICE"] = "WFS",
["VERSION"] = "2.0.0", ["VERSION"] = "2.0.0",
["TYPENAMES"] = "CP:CadastralParcel", ["TYPENAMES"] = typeName,
["SRSNAME"] = "urn:ogc:def:crs:EPSG::6706", ["SRSNAME"] = "urn:ogc:def:crs:EPSG::6706",
["BBOX"] = bbox, ["BBOX"] = bbox,
["REQUEST"] = "GetFeature", ["REQUEST"] = "GetFeature",
["COUNT"] = "20" ["COUNT"] = count.ToString(CultureInfo.InvariantCulture)
}; };
var url = WfsEndpoint + "?" + string.Join("&", query.Select(item => var url = WfsEndpoint + "?" + string.Join("&", query.Select(item =>
+301 -11
View File
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.IO.Compression;
namespace LMEMappeCatastaliApp.Services; namespace LMEMappeCatastaliApp.Services;
@@ -13,8 +14,9 @@ public sealed class ProjectStorageService
}; };
private string ProjectsDirectory => Path.Combine(FileSystem.AppDataDirectory, "Projects"); private string ProjectsDirectory => Path.Combine(FileSystem.AppDataDirectory, "Projects");
private string HistoryDirectory => Path.Combine(ProjectsDirectory, ".history");
public async Task<IReadOnlyList<MapProjectSummary>> GetProjectsAsync() public async Task<IReadOnlyList<MapProjectSummary>> GetProjectsAsync(bool includeArchived = true)
{ {
await _gate.WaitAsync(); await _gate.WaitAsync();
try try
@@ -22,6 +24,7 @@ public sealed class ProjectStorageService
var files = await ReadProjectFilesAsync(); var files = await ReadProjectFilesAsync();
return files return files
.Select(item => item.Document.ToSummary()) .Select(item => item.Document.ToSummary())
.Where(item => includeArchived || !item.IsArchived)
.OrderByDescending(item => item.UpdatedAt) .OrderByDescending(item => item.UpdatedAt)
.ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase) .ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray(); .ToArray();
@@ -56,17 +59,32 @@ public sealed class ProjectStorageService
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var id = existing?.Document.Id ?? Guid.NewGuid().ToString("N"); var id = existing?.Document.Id ?? Guid.NewGuid().ToString("N");
if (existing is not null)
{
Directory.CreateDirectory(HistoryDirectory);
var revisionName = $"{id}--{existing.Document.UpdatedAt:yyyyMMddHHmmssfff}{ProjectExtension}";
File.Copy(existing.Path, Path.Combine(HistoryDirectory, revisionName), true);
PruneHistory(id, 15);
}
var document = new MapProjectDocument var document = new MapProjectDocument
{ {
SchemaVersion = 1, SchemaVersion = 3,
Id = id, Id = id,
Name = normalizedName, Name = normalizedName,
CreatedAt = existing?.Document.CreatedAt ?? now, CreatedAt = existing?.Document.CreatedAt ?? now,
UpdatedAt = now, UpdatedAt = now,
IncludesCadastralParcels = includesCadastralParcels, IncludesCadastralParcels = includesCadastralParcels,
Folder = existing?.Document.Folder ?? string.Empty,
IsArchived = existing?.Document.IsArchived ?? false,
AccessMode = existing?.Document.AccessMode ?? "editable",
Map = snapshotDocument.RootElement.Clone() Map = snapshotDocument.RootElement.Clone()
}; };
if (existing?.Document.AccessMode == "readonly")
{
throw new InvalidOperationException("Il progetto e' in sola lettura. Duplicalo per modificarlo.");
}
var path = existing?.Path ?? Path.Combine(ProjectsDirectory, BuildFileName(normalizedName, id)); var path = existing?.Path ?? Path.Combine(ProjectsDirectory, BuildFileName(normalizedName, id));
var temporaryPath = path + ".tmp"; var temporaryPath = path + ".tmp";
try try
@@ -93,6 +111,224 @@ public sealed class ProjectStorageService
} }
} }
public async Task<MapProjectSummary> DuplicateAsync(string projectId)
{
await _gate.WaitAsync();
try
{
var files = await ReadProjectFilesAsync();
var source = files.FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
var usedNames = files.Select(item => item.Document.Name).ToHashSet(StringComparer.CurrentCultureIgnoreCase);
var baseName = $"{source.Document.Name} - copia";
var name = baseName;
for (var index = 2; usedNames.Contains(name); index++)
{
name = $"{baseName} {index}";
}
var now = DateTimeOffset.UtcNow;
var id = Guid.NewGuid().ToString("N");
var copy = new MapProjectDocument
{
SchemaVersion = 2,
Id = id,
Name = name,
CreatedAt = now,
UpdatedAt = now,
IncludesCadastralParcels = source.Document.IncludesCadastralParcels,
Folder = source.Document.Folder,
AccessMode = "editable",
Map = source.Document.Map.Clone()
};
var path = Path.Combine(ProjectsDirectory, BuildFileName(name, id));
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(copy, _jsonOptions), new UTF8Encoding(false));
return copy.ToSummary();
}
finally { _gate.Release(); }
}
public async Task<MapProjectSummary> RenameAsync(string projectId, string name)
{
var normalizedName = NormalizeName(name);
await _gate.WaitAsync();
try
{
var files = await ReadProjectFilesAsync();
var project = files.FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
if (project.Document.AccessMode == "readonly")
{
throw new InvalidOperationException("Il progetto e' in sola lettura. Duplicalo per modificarlo.");
}
if (files.Any(item => item.Document.Id != projectId
&& string.Equals(item.Document.Name, normalizedName, StringComparison.CurrentCultureIgnoreCase)))
{
throw new InvalidOperationException($"Esiste gia' un progetto chiamato '{normalizedName}'.");
}
project.Document.Name = normalizedName;
project.Document.UpdatedAt = DateTimeOffset.UtcNow;
await File.WriteAllTextAsync(
project.Path,
JsonSerializer.Serialize(project.Document, _jsonOptions),
new UTF8Encoding(false));
return project.Document.ToSummary();
}
finally { _gate.Release(); }
}
public async Task UpdateOrganizationAsync(string projectId, string? folder = null, bool? isArchived = null)
{
await _gate.WaitAsync();
try
{
var project = (await ReadProjectFilesAsync()).FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
project.Document.Folder = NormalizeFolder(folder ?? project.Document.Folder);
project.Document.IsArchived = isArchived ?? project.Document.IsArchived;
project.Document.UpdatedAt = DateTimeOffset.UtcNow;
await File.WriteAllTextAsync(project.Path, JsonSerializer.Serialize(project.Document, _jsonOptions), new UTF8Encoding(false));
}
finally { _gate.Release(); }
}
public async Task<IReadOnlyList<MapProjectVersion>> GetVersionsAsync(string projectId)
{
await _gate.WaitAsync();
try
{
Directory.CreateDirectory(HistoryDirectory);
var versions = new List<MapProjectVersion>();
foreach (var path in Directory.EnumerateFiles(HistoryDirectory, $"{projectId}--*{ProjectExtension}"))
{
try
{
await using var stream = File.OpenRead(path);
var document = await JsonSerializer.DeserializeAsync<MapProjectDocument>(stream, _jsonOptions);
if (document is not null)
{
versions.Add(new(Path.GetFileName(path), document.UpdatedAt));
}
}
catch (JsonException) { }
catch (IOException) { }
}
return versions.OrderByDescending(item => item.UpdatedAt).ToArray();
}
finally { _gate.Release(); }
}
public async Task<string> LoadVersionAsync(string projectId, string versionId)
{
await _gate.WaitAsync();
try
{
if (!versionId.StartsWith($"{projectId}--", StringComparison.Ordinal)
|| Path.GetFileName(versionId) != versionId)
{
throw new InvalidDataException("Revisione non valida.");
}
var path = Path.Combine(HistoryDirectory, versionId);
await using var stream = File.OpenRead(path);
var document = await JsonSerializer.DeserializeAsync<MapProjectDocument>(stream, _jsonOptions)
?? throw new InvalidDataException("Revisione non leggibile.");
return document.Map.GetRawText();
}
finally { _gate.Release(); }
}
public async Task<string> CreateBackupAsync()
{
await _gate.WaitAsync();
try
{
Directory.CreateDirectory(ProjectsDirectory);
var directory = Path.Combine(FileSystem.CacheDirectory, "Backups");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, $"LMEMappeCatastali-backup-{DateTime.Now:yyyyMMdd-HHmmss}.lmebackup.zip");
if (File.Exists(path)) File.Delete(path);
ZipFile.CreateFromDirectory(ProjectsDirectory, path, CompressionLevel.Optimal, false);
return path;
}
finally { _gate.Release(); }
}
public async Task<int> RestoreBackupAsync(string backupPath)
{
await _gate.WaitAsync();
try
{
Directory.CreateDirectory(ProjectsDirectory);
using var archive = ZipFile.OpenRead(backupPath);
var restored = 0;
foreach (var entry in archive.Entries.Where(item => item.FullName.EndsWith(ProjectExtension, StringComparison.OrdinalIgnoreCase)))
{
var fileName = Path.GetFileName(entry.FullName);
if (string.IsNullOrWhiteSpace(fileName)) continue;
var targetDirectory = entry.FullName.Contains(".history/", StringComparison.OrdinalIgnoreCase)
|| entry.FullName.Contains(".history\\", StringComparison.OrdinalIgnoreCase)
? HistoryDirectory : ProjectsDirectory;
Directory.CreateDirectory(targetDirectory);
entry.ExtractToFile(Path.Combine(targetDirectory, fileName), true);
if (targetDirectory == ProjectsDirectory) restored++;
}
return restored;
}
finally { _gate.Release(); }
}
public async Task<string> CreateProjectPackageAsync(string projectId, bool readOnly = false)
{
await _gate.WaitAsync();
try
{
var project = (await ReadProjectFilesAsync()).FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
var directory = Path.Combine(FileSystem.CacheDirectory, "Projects");
Directory.CreateDirectory(directory);
var mode = readOnly ? "readonly" : "editable";
var path = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(project.Path)}-{mode}{ProjectExtension}");
var package = new MapProjectDocument
{
SchemaVersion = 3,
Id = project.Document.Id,
Name = project.Document.Name,
CreatedAt = project.Document.CreatedAt,
UpdatedAt = project.Document.UpdatedAt,
IncludesCadastralParcels = project.Document.IncludesCadastralParcels,
Folder = project.Document.Folder,
IsArchived = false,
AccessMode = mode,
Map = project.Document.Map.Clone()
};
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(package, _jsonOptions), new UTF8Encoding(false));
return path;
}
finally { _gate.Release(); }
}
public async Task<MapProjectSummary> ImportProjectAsync(string path)
{
await _gate.WaitAsync();
try
{
await using var stream = File.OpenRead(path);
var source = await JsonSerializer.DeserializeAsync<MapProjectDocument>(stream, _jsonOptions)
?? throw new InvalidDataException("Progetto non leggibile.");
if (source.Map.ValueKind != JsonValueKind.Object) throw new InvalidDataException("Mappa del progetto non valida.");
source.Id = Guid.NewGuid().ToString("N");
source.Name = NormalizeName(source.Name);
source.CreatedAt = source.UpdatedAt = DateTimeOffset.UtcNow;
source.IsArchived = false;
source.AccessMode = source.AccessMode == "readonly" ? "readonly" : "editable";
Directory.CreateDirectory(ProjectsDirectory);
var target = Path.Combine(ProjectsDirectory, BuildFileName(source.Name, source.Id));
await File.WriteAllTextAsync(target, JsonSerializer.Serialize(source, _jsonOptions), new UTF8Encoding(false));
return source.ToSummary();
}
finally { _gate.Release(); }
}
public async Task<string> LoadAsync(string projectId) public async Task<string> LoadAsync(string projectId)
{ {
await _gate.WaitAsync(); await _gate.WaitAsync();
@@ -118,6 +354,13 @@ public sealed class ProjectStorageService
.FirstOrDefault(item => item.Document.Id == projectId) .FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'."); ?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
File.Delete(project.Path); File.Delete(project.Path);
if (Directory.Exists(HistoryDirectory))
{
foreach (var path in Directory.EnumerateFiles(HistoryDirectory, $"{projectId}--*{ProjectExtension}"))
{
File.Delete(path);
}
}
} }
finally finally
{ {
@@ -125,6 +368,30 @@ public sealed class ProjectStorageService
} }
} }
public async Task<string> CreateScheduledBackupAsync(int keep = 10)
{
var source = await CreateBackupAsync();
var directory = Path.Combine(FileSystem.AppDataDirectory, "AutomaticBackups");
Directory.CreateDirectory(directory);
var target = Path.Combine(directory, $"LME-auto-{DateTime.Now:yyyyMMdd-HHmmss}.lmebackup.zip");
File.Copy(source, target, true);
foreach (var stale in Directory.EnumerateFiles(directory, "*.lmebackup.zip")
.OrderByDescending(File.GetLastWriteTimeUtc).Skip(Math.Clamp(keep, 2, 30)))
{
File.Delete(stale);
}
return target;
}
public DateTimeOffset? GetLatestScheduledBackupTime()
{
var directory = Path.Combine(FileSystem.AppDataDirectory, "AutomaticBackups");
if (!Directory.Exists(directory)) return null;
var latest = Directory.EnumerateFiles(directory, "*.lmebackup.zip")
.OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault();
return latest is null ? null : File.GetLastWriteTimeUtc(latest);
}
private async Task<IReadOnlyList<ProjectFileEntry>> ReadProjectFilesAsync() private async Task<IReadOnlyList<ProjectFileEntry>> ReadProjectFilesAsync()
{ {
Directory.CreateDirectory(ProjectsDirectory); Directory.CreateDirectory(ProjectsDirectory);
@@ -179,19 +446,37 @@ public sealed class ProjectStorageService
return $"{safeName}--{id[..8]}{ProjectExtension}"; return $"{safeName}--{id[..8]}{ProjectExtension}";
} }
private void PruneHistory(string projectId, int keep)
{
foreach (var path in Directory.EnumerateFiles(HistoryDirectory, $"{projectId}--*{ProjectExtension}")
.OrderByDescending(File.GetLastWriteTimeUtc).Skip(keep))
{
File.Delete(path);
}
}
private static string NormalizeFolder(string value)
{
var normalized = string.Join(' ', (value ?? string.Empty).Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries));
return normalized[..Math.Min(normalized.Length, 60)];
}
private sealed record ProjectFileEntry(string Path, MapProjectDocument Document); private sealed record ProjectFileEntry(string Path, MapProjectDocument Document);
private sealed class MapProjectDocument private sealed class MapProjectDocument
{ {
public int SchemaVersion { get; init; } public int SchemaVersion { get; set; }
public string Id { get; init; } = string.Empty; public string Id { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty; public string Name { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; init; } public DateTimeOffset UpdatedAt { get; set; }
public bool IncludesCadastralParcels { get; init; } public bool IncludesCadastralParcels { get; set; }
public JsonElement Map { get; init; } public string Folder { get; set; } = string.Empty;
public bool IsArchived { get; set; }
public string AccessMode { get; set; } = "editable";
public JsonElement Map { get; set; }
public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels); public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels, Folder, IsArchived, AccessMode == "readonly");
} }
} }
@@ -199,4 +484,9 @@ public sealed record MapProjectSummary(
string Id, string Id,
string Name, string Name,
DateTimeOffset UpdatedAt, DateTimeOffset UpdatedAt,
bool IncludesCadastralParcels); bool IncludesCadastralParcels,
string Folder,
bool IsArchived,
bool IsReadOnly);
public sealed record MapProjectVersion(string Id, DateTimeOffset UpdatedAt);
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.401",
"rollForward": "latestPatch"
}
}
+98
View File
@@ -0,0 +1,98 @@
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"
$keytool = "C:\Program Files\Microsoft\jdk-17.0.19.10-hotspot\bin\keytool.exe"
$signingDirectory = Join-Path $env:LOCALAPPDATA "LME\Signing"
$keyStore = Join-Path $signingDirectory "lme-pro-test.keystore"
$passwordFile = Join-Path $signingDirectory "lme-pro-test-password.dpapi"
$outputDirectory = Join-Path $projectRoot "artifacts\android-test"
foreach ($requiredPath in @($dotnet, $keytool, $androidSdk)) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Componente richiesto non trovato: $requiredPath"
}
}
New-Item -ItemType Directory -Force -Path $signingDirectory, $outputDirectory | Out-Null
if (-not (Test-Path -LiteralPath $passwordFile)) {
$passwordBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(36)
$password = [Convert]::ToBase64String($passwordBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
$password | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -LiteralPath $passwordFile
}
$securePassword = (Get-Content -Raw -LiteralPath $passwordFile).Trim() | ConvertTo-SecureString
$credential = [System.Net.NetworkCredential]::new("lme-pro-test", $securePassword)
$plainPassword = $credential.Password
if (-not (Test-Path -LiteralPath $keyStore)) {
& $keytool -genkeypair -noprompt `
-keystore $keyStore `
-storepass $plainPassword `
-keypass $plainPassword `
-alias "lme-pro-test" `
-keyalg RSA `
-keysize 3072 `
-validity 3650 `
-dname "CN=LME Pro Test, OU=Testing, O=LME, L=Rome, C=IT"
if ($LASTEXITCODE -ne 0) {
throw "Creazione della chiave di test non riuscita."
}
}
$env:LME_ANDROID_TEST_STORE_PASS = $plainPassword
$env:LME_ANDROID_TEST_KEY_PASS = $plainPassword
$copiedPackages = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
try {
foreach ($format in @("apk", "aab")) {
& $dotnet publish (Join-Path $projectRoot "LMEMappeCatastaliApp.csproj") `
-f net10.0-android `
-c $Configuration `
-p:AndroidSdkDirectory=$androidSdk `
-p:AndroidPackageFormat=$format `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$keyStore `
-p:AndroidSigningKeyAlias=lme-pro-test `
-p:AndroidSigningStorePass=env:LME_ANDROID_TEST_STORE_PASS `
-p:AndroidSigningKeyPass=env:LME_ANDROID_TEST_KEY_PASS
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 in $publishDirectory"
}
$destination = Join-Path $outputDirectory "LME-Mappe-Catastali-Pro-1.0.0-test.$format"
Copy-Item -LiteralPath $package.FullName -Destination $destination -Force
$copiedPackages.Add((Get-Item -LiteralPath $destination))
}
}
finally {
Remove-Item Env:LME_ANDROID_TEST_STORE_PASS -ErrorAction SilentlyContinue
Remove-Item Env:LME_ANDROID_TEST_KEY_PASS -ErrorAction SilentlyContinue
$plainPassword = $null
$credential = $null
}
$licenseManagerArchive = Join-Path $outputDirectory "LME-Pro-LicenseManager-win-x64.zip"
$filesToHash = @($copiedPackages)
if (Test-Path -LiteralPath $licenseManagerArchive) {
$filesToHash += Get-Item -LiteralPath $licenseManagerArchive
}
$hashLines = $filesToHash | ForEach-Object {
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
"$($hash.Hash.ToLowerInvariant()) $($_.Name)"
}
$hashLines | Set-Content -LiteralPath (Join-Path $outputDirectory "SHA256SUMS.txt")
$copiedPackages | Select-Object Name, Length, LastWriteTime
+33
View File
@@ -0,0 +1,33 @@
param(
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $PSScriptRoot
$dotnet = Join-Path $env:USERPROFILE ".dotnet10\dotnet.exe"
$project = Join-Path $projectRoot "tools\LmeLicenseManager\LmeLicenseManager.csproj"
$publishDirectory = Join-Path $projectRoot "tools\LmeLicenseManager\bin\$Configuration\net10.0-windows\win-x64\publish"
$outputDirectory = Join-Path $projectRoot "artifacts\android-test"
$archive = Join-Path $outputDirectory "LME-Pro-LicenseManager-win-x64.zip"
if (-not (Test-Path -LiteralPath $dotnet)) {
throw "SDK .NET 10 non trovato: $dotnet"
}
& $dotnet run --project $project -- --self-test
if ($LASTEXITCODE -ne 0) {
throw "Self-test del gestore licenze non riuscito."
}
& $dotnet publish $project -c $Configuration -r win-x64 --self-contained true
if ($LASTEXITCODE -ne 0) {
throw "Pubblicazione del gestore licenze non riuscita."
}
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
if (Test-Path -LiteralPath $archive) {
Remove-Item -LiteralPath $archive -Force
}
Compress-Archive -Path (Join-Path $publishDirectory "*") -DestinationPath $archive -CompressionLevel Optimal
Get-Item -LiteralPath $archive | Select-Object Name, Length, LastWriteTime
@@ -0,0 +1,388 @@
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace LmeLicenseManager;
internal sealed class LicenseRepository
{
private const string ProductName = "lme-pro-manual";
private const string EncryptionKeyBase64 = "GiTTRSc1y4DseejPaA0NiXgy95DlgEsIe3JKdJ70sVQ=";
private const string ExpectedPublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAmjh4E1fpaaonATNDipsR
1iy5cNSStD7fgB7i5xJTSZV36Td1B7hQXfZB8Z7vx9iFJpbCQMePuFiDKNbTUQFf
EKEp8lBq7SHo0LvbKNOSK9RMkqpb3ova9Er14gSCgshivf1c774VXTlt9J7uAWoE
QeQMNKJUSogoMkxEKc4Xmye65lGjJVeiuqdDCJh5o42j8DfGwqOyGX4808vSeixe
3lArRmoq9SGNO05VVunF8NKJyhiRLfropUvC+eFsiJMp3VMEYVn5FUcd8ljO67jv
pnngWh6zCZxs+h0mT0YRU9F3tWHqqsG1PE6NZnspmBdNfUfb9LxKt1RshEhUWiBx
0vvdUtg20nESOvFnieIX64pcx0LqFlDMmEWDMQjZ7KEtwXVphN1mhsryUiS6Cz3b
oKTryH4FQY9iIWNNzdzXujrV5ycE49orFEtrXRPnXBlB97Hl3Dlxi19lvPwxWXQ5
fKXrTJ2rL/WvlMpZ5YOggAmv7P7tA4B+Oi5uMiHzXEDlAgMBAAE=
-----END PUBLIC KEY-----
""";
private static readonly byte[] KeyEntropy = Encoding.UTF8.GetBytes("LME-Pro-Manual-License-Signing-v1");
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly string _directory;
private readonly bool _allowKeyGeneration;
private string DatabasePath => Path.Combine(_directory, "licenses.db");
private string PrivateKeyPath => Path.Combine(_directory, "manual-license-private.dpapi");
public string PublicKeyPath => Path.Combine(_directory, "manual-license-public.pem");
public string StoragePath => _directory;
public LicenseRepository(string? directory = null, bool allowKeyGeneration = false)
{
_directory = directory ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LME",
"LicenseManager");
_allowKeyGeneration = allowKeyGeneration;
}
public void Initialize()
{
Directory.CreateDirectory(_directory);
EnsureSigningKey();
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS licenses (
license_id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL,
issued_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
token TEXT NOT NULL,
token_sha256 TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
revoked_at_utc TEXT NULL
);
CREATE INDEX IF NOT EXISTS ix_licenses_email ON licenses(email);
CREATE INDEX IF NOT EXISTS ix_licenses_expires ON licenses(expires_at_utc);
""";
command.ExecuteNonQuery();
}
public IssuedLicense Issue(string firstName, string lastName, string email, DateTimeOffset expiresAt, string notes)
{
firstName = Required(firstName, "Nome");
lastName = Required(lastName, "Cognome");
email = ValidateEmail(email);
var now = DateTimeOffset.UtcNow;
var expiryUtc = expiresAt.ToUniversalTime();
if (expiryUtc <= now.AddMinutes(1))
{
throw new InvalidOperationException("La scadenza deve essere futura.");
}
var payload = new ManualLicensePayload(
1,
ProductName,
Guid.NewGuid().ToString("D"),
firstName,
lastName,
email,
now,
expiryUtc);
var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions);
var envelope = EncryptPayload(plaintext);
byte[] signature;
using (var rsa = LoadPrivateKey())
{
signature = rsa.SignData(envelope, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
var token = $"LME1.{Base64UrlEncode(envelope)}.{Base64UrlEncode(signature)}";
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO licenses (
license_id, first_name, last_name, email, issued_at_utc,
expires_at_utc, token, token_sha256, notes
) VALUES (
$id, $firstName, $lastName, $email, $issuedAt,
$expiresAt, $token, $tokenHash, $notes
);
""";
command.Parameters.AddWithValue("$id", payload.LicenseId);
command.Parameters.AddWithValue("$firstName", payload.FirstName);
command.Parameters.AddWithValue("$lastName", payload.LastName);
command.Parameters.AddWithValue("$email", payload.Email);
command.Parameters.AddWithValue("$issuedAt", payload.IssuedAtUtc.ToString("O"));
command.Parameters.AddWithValue("$expiresAt", payload.ExpiresAtUtc.ToString("O"));
command.Parameters.AddWithValue("$token", token);
command.Parameters.AddWithValue("$tokenHash", tokenHash);
command.Parameters.AddWithValue("$notes", notes.Trim());
command.ExecuteNonQuery();
return new IssuedLicense(payload.LicenseId, firstName, lastName, email, now, expiryUtc, token, notes.Trim(), null);
}
public IReadOnlyList<IssuedLicense> GetAll()
{
var result = new List<IssuedLicense>();
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT license_id, first_name, last_name, email, issued_at_utc,
expires_at_utc, token, notes, revoked_at_utc
FROM licenses
ORDER BY issued_at_utc DESC;
""";
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new IssuedLicense(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
DateTimeOffset.Parse(reader.GetString(4)),
DateTimeOffset.Parse(reader.GetString(5)),
reader.GetString(6),
reader.GetString(7),
reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8))));
}
return result;
}
public bool VerifyToken(string token)
{
try
{
var parts = token.Split('.');
if (parts.Length != 3 || parts[0] != "LME1")
{
return false;
}
var envelope = Base64UrlDecode(parts[1]);
var signature = Base64UrlDecode(parts[2]);
using var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(PublicKeyPath));
if (!rsa.VerifyData(envelope, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
return false;
}
var payload = JsonSerializer.Deserialize<ManualLicensePayload>(DecryptPayload(envelope), JsonOptions);
return payload is { Version: 1, Product: ProductName }
&& payload.ExpiresAtUtc > payload.IssuedAtUtc;
}
catch (Exception error) when (error is FormatException or CryptographicException or JsonException)
{
return false;
}
}
public void MarkRevoked(string licenseId)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = "UPDATE licenses SET revoked_at_utc = $revokedAt WHERE license_id = $id;";
command.Parameters.AddWithValue("$revokedAt", DateTimeOffset.UtcNow.ToString("O"));
command.Parameters.AddWithValue("$id", licenseId);
command.ExecuteNonQuery();
}
public void ExportSigningKeyBackup(string path, string password)
{
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
{
throw new InvalidOperationException("La password del backup deve contenere almeno 12 caratteri.");
}
using var rsa = LoadPrivateKey();
var encrypted = rsa.ExportEncryptedPkcs8PrivateKey(
password,
new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 250_000));
File.WriteAllText(path, PemEncoding.WriteString("ENCRYPTED PRIVATE KEY", encrypted), Encoding.ASCII);
CryptographicOperations.ZeroMemory(encrypted);
}
public void ImportSigningKeyBackup(string path, string password)
{
using var rsa = RSA.Create();
rsa.ImportFromEncryptedPem(File.ReadAllText(path), password);
if (!_allowKeyGeneration && !IsExpectedSigningKey(rsa))
{
throw new CryptographicException("Il backup appartiene a una diversa versione o chiave di LME Pro.");
}
var privateBytes = rsa.ExportPkcs8PrivateKey();
try
{
var protectedBytes = ProtectedData.Protect(privateBytes, KeyEntropy, DataProtectionScope.CurrentUser);
if (File.Exists(PrivateKeyPath))
{
File.SetAttributes(PrivateKeyPath, FileAttributes.Normal);
}
File.WriteAllBytes(PrivateKeyPath, protectedBytes);
File.WriteAllText(PublicKeyPath, rsa.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII);
File.SetAttributes(PrivateKeyPath, FileAttributes.Hidden);
}
finally
{
CryptographicOperations.ZeroMemory(privateBytes);
}
}
private void EnsureSigningKey()
{
if (File.Exists(PrivateKeyPath))
{
using var existing = LoadPrivateKey();
if (!_allowKeyGeneration && !IsExpectedSigningKey(existing))
{
throw new CryptographicException("La chiave locale non corrisponde alla chiave pubblica incorporata in LME Pro.");
}
File.WriteAllText(PublicKeyPath, existing.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII);
return;
}
if (!_allowKeyGeneration)
{
File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII);
return;
}
using var rsa = RSA.Create(3072);
var privateBytes = rsa.ExportPkcs8PrivateKey();
var protectedBytes = ProtectedData.Protect(privateBytes, KeyEntropy, DataProtectionScope.CurrentUser);
CryptographicOperations.ZeroMemory(privateBytes);
File.WriteAllBytes(PrivateKeyPath, protectedBytes);
File.WriteAllText(PublicKeyPath, rsa.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII);
File.SetAttributes(PrivateKeyPath, FileAttributes.Hidden);
}
private static bool IsExpectedSigningKey(RSA rsa)
{
using var expected = RSA.Create();
expected.ImportFromPem(ExpectedPublicKeyPem);
return CryptographicOperations.FixedTimeEquals(
rsa.ExportSubjectPublicKeyInfo(),
expected.ExportSubjectPublicKeyInfo());
}
private RSA LoadPrivateKey()
{
if (!File.Exists(PrivateKeyPath))
{
throw new InvalidOperationException("Chiave di emissione non disponibile. Usa 'Ripristina chiave' e importa il backup .lmekey.");
}
var protectedBytes = File.ReadAllBytes(PrivateKeyPath);
var privateBytes = ProtectedData.Unprotect(protectedBytes, KeyEntropy, DataProtectionScope.CurrentUser);
try
{
var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(privateBytes, out _);
return rsa;
}
finally
{
CryptographicOperations.ZeroMemory(privateBytes);
}
}
private static byte[] EncryptPayload(byte[] plaintext)
{
const int nonceLength = 12;
const int tagLength = 16;
var nonce = RandomNumberGenerator.GetBytes(nonceLength);
var tag = new byte[tagLength];
var ciphertext = new byte[plaintext.Length];
using var aes = new AesGcm(Convert.FromBase64String(EncryptionKeyBase64), tagLength);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
return [.. nonce, .. tag, .. ciphertext];
}
private static byte[] DecryptPayload(byte[] envelope)
{
const int nonceLength = 12;
const int tagLength = 16;
if (envelope.Length <= nonceLength + tagLength)
{
throw new CryptographicException("Invalid license envelope.");
}
var plaintext = new byte[envelope.Length - nonceLength - tagLength];
using var aes = new AesGcm(Convert.FromBase64String(EncryptionKeyBase64), tagLength);
aes.Decrypt(
envelope.AsSpan(0, nonceLength),
envelope.AsSpan(nonceLength + tagLength),
envelope.AsSpan(nonceLength, tagLength),
plaintext);
return plaintext;
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={DatabasePath};Pooling=False");
connection.Open();
return connection;
}
private static string Base64UrlEncode(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
private static string Required(string value, string fieldName)
{
var normalized = value.Trim();
return normalized.Length > 0 ? normalized : throw new InvalidOperationException($"{fieldName} obbligatorio.");
}
private static string ValidateEmail(string value)
{
try
{
return new MailAddress(Required(value, "Email")).Address;
}
catch (FormatException)
{
throw new InvalidOperationException("Indirizzo email non valido.");
}
}
private sealed record ManualLicensePayload(
int Version,
string Product,
string LicenseId,
string FirstName,
string LastName,
string Email,
DateTimeOffset IssuedAtUtc,
DateTimeOffset ExpiresAtUtc);
}
internal sealed record IssuedLicense(
string LicenseId,
string FirstName,
string LastName,
string Email,
DateTimeOffset IssuedAtUtc,
DateTimeOffset ExpiresAtUtc,
string Token,
string Notes,
DateTimeOffset? RevokedAtUtc)
{
public string Name => $"{FirstName} {LastName}";
public string Status => RevokedAtUtc is not null ? "Revocata (registro)" : ExpiresAtUtc <= DateTimeOffset.UtcNow ? "Scaduta" : "Attiva";
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>LME.Pro.LicenseManager</AssemblyName>
<RootNamespace>LmeLicenseManager</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
</Project>
+393
View File
@@ -0,0 +1,393 @@
using System.Diagnostics;
namespace LmeLicenseManager;
internal sealed class MainForm : Form
{
private readonly LicenseRepository _repository;
private readonly TextBox _firstName = new();
private readonly TextBox _lastName = new();
private readonly TextBox _email = new();
private readonly DateTimePicker _expiresAt = new();
private readonly TextBox _notes = new();
private readonly TextBox _licenseCode = new();
private readonly DataGridView _grid = new();
private readonly Label _status = new();
private IReadOnlyList<IssuedLicense> _licenses = [];
public MainForm(LicenseRepository repository)
{
_repository = repository;
Text = "LME Pro - Gestore licenze manuali";
MinimumSize = new Size(980, 720);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9F);
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 5,
Padding = new Padding(18)
};
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
Controls.Add(root);
root.Controls.Add(BuildHeader(), 0, 0);
root.Controls.Add(BuildIssueForm(), 0, 1);
root.Controls.Add(BuildCodePanel(), 0, 2);
root.Controls.Add(BuildGrid(), 0, 3);
_status.AutoSize = true;
_status.Padding = new Padding(0, 8, 0, 0);
_status.ForeColor = Color.FromArgb(60, 77, 88);
root.Controls.Add(_status, 0, 4);
RefreshLicenses();
}
private Control BuildHeader()
{
var panel = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 2 };
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
var text = new Label
{
AutoSize = true,
Text = "Licenze manuali LME Pro\r\nEmetti codici cifrati e conserva titolari e scadenze nel registro locale.",
Font = new Font("Segoe UI", 12F, FontStyle.Bold),
Padding = new Padding(0, 0, 0, 14)
};
panel.Controls.Add(text, 0, 0);
var commands = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, Margin = new Padding(8, 0, 0, 12) };
var openFolder = new Button { Text = "Apri archivio", AutoSize = true };
openFolder.Click += (_, _) => Process.Start(new ProcessStartInfo("explorer.exe", _repository.StoragePath) { UseShellExecute = true });
var exportKey = new Button { Text = "Backup chiave", AutoSize = true };
exportKey.Click += (_, _) => ExportSigningKey();
var importKey = new Button { Text = "Ripristina chiave", AutoSize = true };
importKey.Click += (_, _) => ImportSigningKey();
commands.Controls.Add(openFolder);
commands.Controls.Add(exportKey);
commands.Controls.Add(importKey);
panel.Controls.Add(commands, 1, 0);
return panel;
}
private Control BuildIssueForm()
{
var group = new GroupBox { Text = "Nuova licenza", Dock = DockStyle.Top, AutoSize = true, Padding = new Padding(12) };
var form = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 4, RowCount = 3 };
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25));
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25));
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30));
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 20));
AddLabeledControl(form, "Nome", _firstName, 0, 0);
AddLabeledControl(form, "Cognome", _lastName, 1, 0);
AddLabeledControl(form, "Email", _email, 2, 0);
_expiresAt.Format = DateTimePickerFormat.Custom;
_expiresAt.CustomFormat = "dd/MM/yyyy HH:mm";
_expiresAt.Value = DateTime.Now.AddYears(1);
AddLabeledControl(form, "Scadenza", _expiresAt, 3, 0);
_notes.PlaceholderText = "Riferimento fattura, cliente, note interne...";
AddLabeledControl(form, "Note", _notes, 0, 1, 3);
var issue = new Button
{
Text = "Emetti licenza",
Dock = DockStyle.Fill,
Height = 36,
Margin = new Padding(6, 23, 3, 4),
BackColor = Color.FromArgb(7, 94, 84),
ForeColor = Color.White,
FlatStyle = FlatStyle.Flat
};
issue.Click += (_, _) => IssueLicense();
form.Controls.Add(issue, 3, 1);
var note = new Label
{
AutoSize = true,
Text = "La revoca registrata qui non disattiva un codice già consegnato e offline: la disattivazione effettiva avviene alla scadenza.",
ForeColor = Color.FromArgb(90, 103, 111),
Padding = new Padding(3, 8, 3, 2)
};
form.Controls.Add(note, 0, 2);
form.SetColumnSpan(note, 4);
group.Controls.Add(form);
return group;
}
private Control BuildCodePanel()
{
var group = new GroupBox { Text = "Codice da consegnare", Dock = DockStyle.Top, Height = 122, Padding = new Padding(12) };
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2 };
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
_licenseCode.Multiline = true;
_licenseCode.ReadOnly = true;
_licenseCode.ScrollBars = ScrollBars.Vertical;
_licenseCode.Dock = DockStyle.Fill;
_licenseCode.Font = new Font("Consolas", 8.5F);
layout.Controls.Add(_licenseCode, 0, 0);
var copy = new Button { Text = "Copia codice", AutoSize = true, Dock = DockStyle.Top, Margin = new Padding(8, 0, 0, 0) };
copy.Click += (_, _) => CopyCurrentCode();
layout.Controls.Add(copy, 1, 0);
group.Controls.Add(layout);
return group;
}
private Control BuildGrid()
{
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2, Padding = new Padding(0, 12, 0, 0) };
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var commands = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Top, FlowDirection = FlowDirection.LeftToRight };
var refresh = new Button { Text = "Aggiorna registro", AutoSize = true };
refresh.Click += (_, _) => RefreshLicenses();
var revoke = new Button { Text = "Segna revocata", AutoSize = true };
revoke.Click += (_, _) => RevokeSelected();
commands.Controls.Add(refresh);
commands.Controls.Add(revoke);
panel.Controls.Add(commands, 0, 0);
_grid.Dock = DockStyle.Fill;
_grid.ReadOnly = true;
_grid.AllowUserToAddRows = false;
_grid.AllowUserToDeleteRows = false;
_grid.AutoGenerateColumns = true;
_grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
_grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
_grid.MultiSelect = false;
_grid.SelectionChanged += (_, _) => ShowSelectedCode();
panel.Controls.Add(_grid, 0, 1);
return panel;
}
private void IssueLicense()
{
try
{
var expiry = new DateTimeOffset(_expiresAt.Value);
var license = _repository.Issue(_firstName.Text, _lastName.Text, _email.Text, expiry, _notes.Text);
_licenseCode.Text = license.Token;
Clipboard.SetText(license.Token);
_status.Text = $"Licenza {ShortId(license.LicenseId)} emessa e copiata negli appunti. Scadenza: {license.ExpiresAtUtc.ToLocalTime():dd/MM/yyyy HH:mm}.";
RefreshLicenses(license.LicenseId);
}
catch (Exception error)
{
MessageBox.Show(this, error.GetBaseException().Message, "Licenza non emessa", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void RefreshLicenses(string? selectLicenseId = null)
{
_licenses = _repository.GetAll();
_grid.DataSource = _licenses.Select(item => new
{
Id = ShortId(item.LicenseId),
item.Name,
item.Email,
Emessa = item.IssuedAtUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm"),
Scadenza = item.ExpiresAtUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm"),
item.Status,
item.Notes,
LicenseId = item.LicenseId
}).ToList();
if (selectLicenseId is not null)
{
foreach (DataGridViewRow row in _grid.Rows)
{
if (Equals(row.Cells["LicenseId"].Value, selectLicenseId))
{
row.Selected = true;
_grid.CurrentCell = row.Cells[0];
break;
}
}
}
if (_grid.Columns.Contains("LicenseId"))
{
_grid.Columns["LicenseId"]!.Visible = false;
}
_status.Text = $"{_licenses.Count} licenze registrate. Archivio: {_repository.StoragePath}";
}
private void ShowSelectedCode()
{
var selected = GetSelectedLicense();
if (selected is not null)
{
_licenseCode.Text = selected.Token;
}
}
private void CopyCurrentCode()
{
if (!string.IsNullOrWhiteSpace(_licenseCode.Text))
{
Clipboard.SetText(_licenseCode.Text);
_status.Text = "Codice licenza copiato negli appunti.";
}
}
private void RevokeSelected()
{
var selected = GetSelectedLicense();
if (selected is null || selected.RevokedAtUtc is not null)
{
return;
}
var answer = MessageBox.Show(
this,
"La revoca verrà annotata nel registro, ma un'app offline continuerà a riconoscere il codice fino alla scadenza. Continuare?",
"Segna licenza revocata",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning);
if (answer != DialogResult.Yes)
{
return;
}
_repository.MarkRevoked(selected.LicenseId);
RefreshLicenses(selected.LicenseId);
}
private void ExportSigningKey()
{
var password = PromptPassword("Password backup", "Scegli una password di almeno 12 caratteri per cifrare la chiave.", true);
if (password is null) return;
using var dialog = new SaveFileDialog
{
Title = "Salva backup della chiave licenze",
Filter = "Backup chiave LME (*.lmekey)|*.lmekey",
FileName = $"LME-Pro-licenze-{DateTime.Now:yyyyMMdd}.lmekey",
AddExtension = true
};
if (dialog.ShowDialog(this) != DialogResult.OK) return;
try
{
_repository.ExportSigningKeyBackup(dialog.FileName, password);
_status.Text = "Backup cifrato della chiave creato. Conserva file e password separatamente.";
}
catch (Exception error)
{
MessageBox.Show(this, error.GetBaseException().Message, "Backup non creato", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void ImportSigningKey()
{
using var dialog = new OpenFileDialog
{
Title = "Apri backup della chiave licenze",
Filter = "Backup chiave LME (*.lmekey)|*.lmekey"
};
if (dialog.ShowDialog(this) != DialogResult.OK) return;
var password = PromptPassword("Password backup", "Inserisci la password usata per cifrare questa chiave.", false);
if (password is null) return;
var answer = MessageBox.Show(
this,
"La chiave di emissione corrente verrà sostituita. Importa soltanto il backup associato a questa versione dell'app.",
"Ripristina chiave licenze",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning);
if (answer != DialogResult.Yes) return;
try
{
_repository.ImportSigningKeyBackup(dialog.FileName, password);
_status.Text = "Chiave di emissione ripristinata dal backup.";
}
catch (Exception error)
{
MessageBox.Show(this, error.GetBaseException().Message, "Chiave non importata", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private string? PromptPassword(string title, string message, bool confirm)
{
using var dialog = new Form
{
Text = title,
Width = 470,
Height = confirm ? 245 : 205,
StartPosition = FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.FixedDialog,
MaximizeBox = false,
MinimizeBox = false
};
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(14), RowCount = confirm ? 5 : 4, ColumnCount = 1 };
var explanation = new Label { Text = message, AutoSize = true, MaximumSize = new Size(420, 0) };
var password = new TextBox { UseSystemPasswordChar = true, Dock = DockStyle.Top };
var confirmation = new TextBox { UseSystemPasswordChar = true, Dock = DockStyle.Top, Visible = confirm };
var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill, AutoSize = true };
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, AutoSize = true };
var cancel = new Button { Text = "Annulla", DialogResult = DialogResult.Cancel, AutoSize = true };
buttons.Controls.Add(ok);
buttons.Controls.Add(cancel);
layout.Controls.Add(explanation);
layout.Controls.Add(new Label { Text = "Password", AutoSize = true });
layout.Controls.Add(password);
if (confirm)
{
var confirmationPanel = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, RowCount = 2 };
confirmationPanel.Controls.Add(new Label { Text = "Conferma password", AutoSize = true });
confirmationPanel.Controls.Add(confirmation);
layout.Controls.Add(confirmationPanel);
}
layout.Controls.Add(buttons);
dialog.Controls.Add(layout);
dialog.AcceptButton = ok;
dialog.CancelButton = cancel;
if (dialog.ShowDialog(this) != DialogResult.OK) return null;
if (confirm && password.Text != confirmation.Text)
{
MessageBox.Show(this, "Le password non coincidono.", title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return null;
}
return password.Text;
}
private IssuedLicense? GetSelectedLicense()
{
if (_grid.CurrentRow?.Cells["LicenseId"].Value is not string id)
{
return null;
}
return _licenses.FirstOrDefault(item => item.LicenseId == id);
}
private static void AddLabeledControl(TableLayoutPanel form, string labelText, Control control, int column, int row, int columnSpan = 1)
{
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, RowCount = 2, Margin = new Padding(3) };
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.Controls.Add(new Label { Text = labelText, AutoSize = true }, 0, 0);
control.Dock = DockStyle.Top;
control.Margin = new Padding(0, 4, 0, 0);
panel.Controls.Add(control, 0, 1);
form.Controls.Add(panel, column, row);
if (columnSpan > 1)
{
form.SetColumnSpan(panel, columnSpan);
}
}
private static string ShortId(string id) => id.Length > 8 ? id[..8].ToUpperInvariant() : id;
}
+80
View File
@@ -0,0 +1,80 @@
namespace LmeLicenseManager;
internal static class Program
{
[STAThread]
private static void Main(string[] args)
{
if (args.Contains("--self-test", StringComparer.OrdinalIgnoreCase))
{
RunSelfTest();
return;
}
var repository = new LicenseRepository();
repository.Initialize();
if (args.Contains("--init", StringComparer.OrdinalIgnoreCase))
{
return;
}
ApplicationConfiguration.Initialize();
Application.Run(new MainForm(repository));
}
private static void RunSelfTest()
{
var testDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}");
var importedDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}");
const string backupPassword = "LME-Test-Backup-Password-2026";
try
{
var repository = new LicenseRepository(testDirectory, allowKeyGeneration: true);
repository.Initialize();
var license = repository.Issue(
"Mario",
"Rossi",
"mario.rossi@example.test",
DateTimeOffset.Now.AddDays(30),
"Test automatico");
if (!repository.VerifyToken(license.Token))
{
throw new InvalidOperationException("Il codice emesso non supera la verifica crittografica.");
}
var backupPath = Path.Combine(testDirectory, "signing-key.lmekey");
repository.ExportSigningKeyBackup(backupPath, backupPassword);
var importedRepository = new LicenseRepository(importedDirectory, allowKeyGeneration: true);
importedRepository.Initialize();
importedRepository.ImportSigningKeyBackup(backupPath, backupPassword);
var portableLicense = importedRepository.Issue(
"Giulia",
"Bianchi",
"giulia.bianchi@example.test",
DateTimeOffset.Now.AddYears(1),
"Test portabilita chiave");
if (!repository.VerifyToken(portableLicense.Token)
|| !importedRepository.VerifyToken(license.Token))
{
throw new InvalidOperationException("La chiave importata non produce licenze compatibili.");
}
Console.WriteLine("Self-test licenze e portabilita chiave completato.");
}
finally
{
foreach (var directory in new[] { testDirectory, importedDirectory })
{
var fullPath = Path.GetFullPath(directory);
var tempRoot = Path.GetFullPath(Path.GetTempPath());
if (Directory.Exists(fullPath)
&& fullPath.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase)
&& Path.GetFileName(fullPath).StartsWith("LME-License-SelfTest-", StringComparison.Ordinal))
{
Directory.Delete(fullPath, true);
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LME.Pro.LicenseManager" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+1027 -14
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -31,6 +31,7 @@
<script src="lib/googlemutant/Leaflet.GoogleMutant.js"></script> <script src="lib/googlemutant/Leaflet.GoogleMutant.js"></script>
<script src="lib/jszip/jszip.min.js"></script> <script src="lib/jszip/jszip.min.js"></script>
<script src="lib/togeojson/togeojson.umd.js"></script> <script src="lib/togeojson/togeojson.umd.js"></script>
<script src="lib/turf/turf.min.js"></script>
<script src="js/lme-map.js"></script> <script src="js/lme-map.js"></script>
</body> </body>
+3617 -169
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).turf={})}(this,(function(t){"use strict";function e(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,r=Array(e);n<e;n++)r[n]=t[n];return r}function n(t){if(Array.isArray(t))return t}function r(t,e,n){return e=l(e),function(t,e){if(e&&("object"==typeof e||"function"==typeof e))return e;if(void 0!==e)throw new TypeError("Derived constructors may only return object or undefined");return function(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}(t)}(t,c()?Reflect.construct(e,n||[],l(t).constructor):e.apply(t,n))}function i(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function o(t,e){for(var n=0;n<e.length;n++){var r=e[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,y(r.key),r)}}function s(t,e,n){return e&&o(t.prototype,e),n&&o(t,n),Object.defineProperty(t,"prototype",{writable:!1}),t}function a(t,e){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=_(t))||e){n&&(t=n);var r=0,i=function(){};return{s:i,n:function(){return r>=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,s=!0,a=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return s=t.done,t},e:function(t){a=!0,o=t},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw o}}}}function u(t,e,n){return(e=y(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function l(t){return l=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},l(t)}function h(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),Object.defineProperty(t,"prototype",{writable:!1}),e&&p(t,e)}function c(){try{var t=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(t){}return(c=function(){return!!t})()}function f(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}function g(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function p(t,e){return p=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},p(t,e)}function v(t,e){return n(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var r,i,o,s,a=[],u=!0,l=!1;try{if(o=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;u=!1}else for(;!(u=(r=o.call(n)).done)&&(a.push(r.value),a.length!==e);u=!0);}catch(t){l=!0,i=t}finally{try{if(!u&&null!=n.return&&(s=n.return(),Object(s)!==s))return}finally{if(l)throw i}}return a}}(t,e)||_(t,e)||g()}function d(t){return function(t){if(Array.isArray(t))return e(t)}(t)||f(t)||_(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function y(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var r=n.call(t,e||"default");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:e+""}function m(t){return m="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},m(t)}function _(t,n){if(t){if("string"==typeof t)return e(t,n);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?e(t,n):void 0}}var x=6371008.8,E={centimeters:100*x,centimetres:100*x,degrees:360/(2*Math.PI),feet:3.28084*x,inches:39.37*x,kilometers:x/1e3,kilometres:x/1e3,meters:x,metres:x,miles:x/1609.344,millimeters:1e3*x,millimetres:1e3*x,nauticalmiles:x/1852,radians:1,yards:1.0936*x},k={acres:247105e-9,centimeters:1e4,centimetres:1e4,feet:10.763910417,hectares:1e-4,inches:1550.003100006,kilometers:1e-6,kilometres:1e-6,meters:1,metres:1,miles:386e-9,nauticalmiles:2.9155334959812285e-7,millimeters:1e6,millimetres:1e6,yards:1.195990046};function b(t,e){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r={type:"Feature"};return(0===Line truncated
/*!
* Copyright (c) 2019, Dane Springmeyer
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in
* the documentation and/or other materials provided with the
* distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
function ta(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=[];if(mt(t,(function(t){n.push(t.coordinates)})),n.length<2)throw new Error("Must specify at least 2 geometries");var r=Rs.apply(Fs,[n[0]].concat(d(n.slice(1))));return 0===r.length?null:1===r.length?S(r[0],e.properties):R(r,e.properties)}function ea(t,e){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=JSON.stringify(n.properties||{}),i=v(t,4),o=i[0],s=i[1],a=i[2],u=i[3],l=(s+u)/2,h=(o+a)/2,c=2*e/ut([o,l],[a,l],n)*(a-o),f=2*e/ut([h,s],[h,u],n)*(u-s),g=c/2,p=2*g,d=Math.sqrt(3)/2*f,y=a-o,m=u-s,_=3/4*p,x=d,E=(y-p)/(p-g/2),k=Math.floor(E),b=(k*_-g/2-y)/2-g/2+_/2,w=Math.floor((m-d)/d),I=(m-w*d)/2,N=w*d-m>d/2;N&&(I-=d/4);for(var S=[],M=[],L=0;L<6;L++){var P=2*Math.PI/6*L;S.push(Math.cos(P)),M.push(Math.sin(P))}for(var T=[],O=0;O<=k;O++)for(var R=0;R<=w;R++){var A=O%2==1;if((0!==R||!A)&&(0!==R||!N)){var D=O*_+o-b,F=R*x+s+I;if(A&&(F-=d/2),!0===n.triangles)ra([D,F],c/2,f/2,JSON.parse(r),S,M).forEach((function(t){n.mask?ta(C([n.mask,t]))&&T.push(t):T.push(t)}));else{var q=na([D,F],c/2,f/2,JSON.parse(r),S,M);n.mask?ta(C([n.mask,q]))&&T.push(q):T.push(q)}}}return C(T)}function na(t,e,n,r,i,o){for(var s=[],a=0;a<6;a++){var u=t[0]+e*i[a],l=t[1]+n*o[a];s.push([u,l])}return s.push(s[0].slice()),S([s],r)}function ra(t,e,n,r,i,o){for(var s=[],a=0;a<6;a++){var u=[];u.push(t),u.push([t[0]+e*i[a],t[1]+n*o[a]]),u.push([t[0]+e*i[(a+1)%6],t[1]+n*o[(a+1)%6]]),u.push(t),s.push(S([u],r))}return s}function ia(t,e){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};n.mask&&!n.units&&(n.units="kilometers");for(var r=[],i=t[0],o=t[1],s=t[2],a=t[3],u=e/ut([i,o],[s,o],n)*(s-i),l=e/ut([i,o],[i,a],n)*(a-o),h=s-i,c=a-o,f=Math.floor(h/u),g=(c-Math.floor(c/l)*l)/2,p=i+(h-f*u)/2;p<=s;){for(var v=o+g;v<=a;){var d=I([p,v],n.properties);n.mask?Cn(d,n.mask)&&r.push(d):r.push(d),v+=l}p+=u}return C(r)}function oa(t,e,n){for(var r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},i=[],o=t[0],s=t[1],a=t[2],u=t[3],l=a-o,h=j(e,r.units,"degrees"),c=u-s,f=j(n,r.units,"degrees"),g=Math.floor(Math.abs(l)/h),p=Math.floor(Math.abs(c)/f),v=(c-p*f)/2,d=o+(l-g*h)/2,y=0;y<g;y++){for(var m=s+v,_=0;_<p;_++){var x=S([[[d,m],[d,m+f],[d+h,m+f],[d+h,m],[d,m]]],r.properties);r.mask?Oe(r.mask,x)&&i.push(x):i.push(x),m+=f}d+=h}return C(i)}function sa(t,e){return oa(t,e,e,arguments.length>2&&void 0!==arguments[2]?arguments[2]:{})}function aa(t,e){for(var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=[],i=e/ut([t[0],t[1]],[t[2],t[1]],n)*(t[2]-t[0]),o=e/ut([t[0],t[1]],[t[0],t[3]],n)*(t[3]-t[1]),s=0,a=t[0];a<=t[2];){for(var u=0,l=t[1];l<=t[3];){var h=null,c=null;s%2==0&&u%2==0?(h=S([[[a,l],[a,l+o],[a+i,l],[a,l]]],n.properties),c=S([[[a,l+o],[a+i,l+o],[a+i,l],[a,l+o]]],n.properties)):s%2==0&&u%2==1?(h=S([[[a,l],[a+i,l+o],[a+i,l],[a,l]]],n.properties),c=S([[[a,l],[a,l+o],[a+i,l+o],[a,l]]],n.properties)):u%2==0&&s%2==1?(h=S([[[a,l],[a,l+o],[a+i,l+o],[a,l]]],n.properties),c=S([[[a,l],[a+i,l+o],[a+i,l],[a,l]]],n.properties)):u%2==1&&s%2==1&&(h=S([[[a,l],[a,l+o],[a+i,l],[a,l]]],n.properties),c=S([[[a,l+o],[a+i,l+o],[a+i,l],[a,l+o]]],n.properties)),n.mask?(ta(C([n.mask,h]))&&r.push(h),ta(C([n.mask,c]))&&r.push(c)):(r.push(h),r.push(c)),l+=o,u++}s++,a+=i}return C(r)}
/*!
* MarchingSquaresJS
* version 1.3.3
* https://github.com/RaumZeit/MarchingSquares.js
*
* @license GNU Affero General Public License.
* Copyright (c) 2015-2019 Ronny Lorenz <ronny@tbi.univie.ac.at>
*/
function ua(t,e,n){return t<e?(n-t)/(e-t):(t-n)/(t-e)}function la(t,e,n,r){var i;return n>r&&(i=n,n=r,r=i),t<e?t<n?(n-t)/(e-t):(r-t)/(e-t):t>r?(t-r)/(t-e):(t-n)/(t-e)}function ha(t,e,n,r){return t<e?(n-t)/(e-t):(t-r)/(t-e)}function ca(t,e,n,r){return t<e?(r-t)/(e-t):(t-n)/(t-e)}function fa(){this.successCallback=null,this.verbose=!1,this.polygons=!1,this.polygons_full=!1,this.linearRing=!0,this.noQuadTree=!1,this.noFrame=!1}function ga(t,e,n,r){var i=[];return t.polygons.forEach((function(t){t.forEach((function(t){t[0]+=e,t[1]+=n})),r.linearRing&&t.push(t[0]),i.push(t)})),i}function pa(t,e,n,r){return 0===n?(t+=1,e+=r[0][1]):1===n?t+=r[0][0]:2===n?e+=r[0][1]:3===n&&(t+=r[0][0],e+=1),[t,e]}function va(t,e,n){return 0===n?t++:1===n||(2===n?e++:3===n&&(t++,e++)),[t,e]}function da(t,e,n,r,i){var o=r,s=i,a=0,u=0;if(this.x=e,this.y=n,this.lowerBound=null,this.upperBound=null,this.childA=null,this.childB=null,this.childC=null,this.childD=null,1===r&&1===i)this.lowerBound=Math.min(t[n][e],t[n][e+1],t[n+1][e+1],t[n+1][e]),this.upperBound=Math.max(t[n][e],t[n][e+1],t[n+1][e+1],t[n+1][e]);else{if(r>1){for(;0!==o;)o>>=1,a++;r===1<<a-1&&a--,o=1<<a-1}if(i>1){for(;0!==s;)s>>=1,u++;i===1<<u-1&&u--,s=1<<u-1}this.childA=new da(t,e,n,o,s),this.lowerBound=this.childA.lowerBound,this.upperBound=this.childA.upperBound,r-o>0&&(this.childB=new da(t,e+o,n,r-o,s),this.lowerBound=Math.min(this.lowerBound,this.childB.lowerBound),this.upperBound=Math.max(this.upperBound,this.childB.upperBound),i-s>0&&(this.childC=new da(t,e+o,n+s,r-o,i-s),this.lowerBound=Math.min(this.lowerBound,this.childC.lowerBound),this.upperBound=Math.max(this.upperBound,this.childC.upperBound))),i-s>0&&(this.childD=new da(t,e,n+s,o,i-s),this.lowerBound=Math.min(this.lowerBound,this.childD.lowerBound),this.upperBound=Math.max(this.upperBound,this.childD.upperBound))}}function ya(t){var e,n;if(!t)throw new Error("data is required");if(!Array.isArray(t)||!Array.isArray(t[0]))throw new Error("data must be scalar field, i.e. array of arrays");if(t.length<2)throw new Error("data must contain at least two rows");if((n=t[0].length)<2)throw new Error("data must contain at least two columns");for(e=1;e<t.length;e++){if(!Array.isArray(t[e]))throw new Error("Row "+e+" is not an array");if(t[e].length!=n)throw new Error("unequal row lengths detected, please provide a regular grid")}this.data=t,this.root=new da(t,0,0,t[0].length-1,t.length-1)}function ma(t,e,n){var r,i,o,s=!1,a=!1,u=null,l=null,h=null,c=null,f=null,g=[];if(!t)throw new Error("data is required");if(null==e)throw new Error("threshold is required");if(n&&"object"!==m(n))throw new Error("options must be an object");if(r=function(t){var e,n,r,i,o;for(i=new fa,t=t||{},o=Object.keys(i),e=0;e<o.length;e++)null!=(r=t[n=o[e]])&&(i[n]=r);return i.polygons_full=!i.polygons,i.interpolate=ua,i}(n),t instanceof ya)u=t,l=t.root,h=t.data,r.noQuadTree||(s=!0);else{if(!Array.isArray(t)||!Array.isArray(t[0]))throw new Error("input is neither array of arrays nor object retrieved from 'QuadTree()'");h=t}if(Array.isArray(e)){for(a=!0,r.noQuadTree||(s=!0),i=0;i<e.length;i++)if(isNaN(+e[i]))throw new Error("threshold["+i+"] is not a number")}else{if(isNaN(+e))throw new Error("threshold must be a number or array of numbers");e=[e]}return s&&!l&&(u=new ya(h),l=u.root,h=u.data),r.verbose&&(r.polygons?console.log("MarchingSquaresJS-isoLines: returning single lines (polygons) for each grid cell"):console.log("MarchingSquaresJS-isoLines: returning line paths (polygons) for entire data grid"),a&&console.log("MarchingSquaresJS-isoLines: multiple lines requested, returning array of line paths instead of lines for a single threshold")),e.forEach((function(t,e){if(f=[],r.threshold=t,r.verbose&&console.log("MarchingSquaresJS-isoLines: computing iso lines for threshold "+t),r.polygons)if(s)l.cellsBelowThreshold(r.threshold,!0).forEach((function(t){f=f.concat(ga(_a(h,t.x,t.y,r),t.x,t.y,r))}));else for(o=0;o<h.length-1;++o)for(e=0;e<h[0].length-1;++e)f=f.concat(ga(_a(h,e,o,r),e,o,r));else{for(c=[],e=0;e<h[0].length-1;++e)c[e]=[];if(s)l.cellsBelowThreshold(r.threshold,!1).forEach((function(t){c[t.x][t.y]=_a(h,t.x,t.y,r)}));else for(e=0;e<h[0].length-1;++e)for(o=0;o<h.length-1;++o)c[e][o]=_a(h,e,o,r);f=function(t,e,n){var r,i,o,s,a,u,l,h,c,f,g,p,v,d,y,_=[],x=t.length-1,E=t[0].length-1,k=["right","bottom","left","top"],b=[0,-1,0,1],w=[-1,0,1,0],I={bottom:1,left:2,top:3,right:0};return n.noFrame||function(t,e){var n,r,i,o,s;for(n=!0,r=t[0].length,i=t.length,s=0;s<i;s++)if(t[s][0]>=e||t[s][r-1]>=e){n=!1;break}if(n&&(t[i-1][0]>=e||t[i-1][r-1]>=e)&&(n=!1),n)for(o=0;o<r-1;o++)if(t[0][o]>=e||t[i-1][o]>e){n=!1;break}return n}(t,n.threshold)&&(n.linearRing?_.push([[0,0],[0,x],[E,x],[E,0],[0,0]]):_.push([[0,0],[0,x],[E,x],[E,0]])),e.forEach((function(t,N){t.forEach((function(t,S){for(r=null,i=0;i<4;i++)if(r=k[i],"object"===m(t.edges[r])){for(a=[],o=t.edges[r],u=r,l=N,h=S,c=!1,f=[N+o.path[0][0],S+o.path[0][1]],a.push(f);!c&&"object"===m((s=e[l][h]).edges[u]);)iLine truncated