Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f28c5621 | ||
|
|
11a91863a3 | ||
|
|
b600412285 | ||
|
|
f65451c414 |
No files matched your search
@@ -14,3 +14,4 @@ obj/
|
||||
*.p12
|
||||
*.log
|
||||
TestResults/
|
||||
artifacts/
|
||||
@@ -1,7 +1,9 @@
|
||||
@inject AppLocalizationService L10n
|
||||
|
||||
<details class="menu-help">
|
||||
<summary>
|
||||
<span aria-hidden="true">?</span>
|
||||
<strong>Guida rapida</strong>
|
||||
<strong>@L10n.T("menu.quickHelp")</strong>
|
||||
</summary>
|
||||
<div class="menu-help-content">
|
||||
<p>@Introduction</p>
|
||||
@@ -57,7 +59,7 @@
|
||||
"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 importa pure cornici e riquadri generali; disattivalo quando vuoi conservare soltanto le particelle riconosciute.",
|
||||
"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.",
|
||||
@@ -83,7 +85,7 @@
|
||||
"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: seleziona una data e usa Apri revisione per consultarne lo stato oppure Confronta per vedere le differenze con la versione corrente.",
|
||||
"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" =>
|
||||
|
||||
+282
-28
@@ -10,9 +10,11 @@
|
||||
@inject MapExportService MapExportService
|
||||
@inject CloudSyncService CloudSyncService
|
||||
@inject AppUpdateService AppUpdateService
|
||||
@inject AppLocalizationService L10n
|
||||
@inject EntitlementService Entitlements
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<PageTitle>LME Mappe Catastali</PageTitle>
|
||||
<PageTitle>LME Mappe Catastali Pro</PageTitle>
|
||||
|
||||
<div class="map-app">
|
||||
<section class="map-stage @(ActiveTab == "search" ? "search-active" : null)">
|
||||
@@ -40,14 +42,14 @@
|
||||
@if (NavigationMenuOpen)
|
||||
{
|
||||
<nav class="map-navigation-menu" aria-label="Menu principale">
|
||||
<button @onclick='() => SelectTab("map")'>Mappa</button>
|
||||
<button @onclick='() => SelectTab("search")'>Cerca località</button>
|
||||
<button @onclick='() => SelectTab("cadastre")'>Ricerca catastale</button>
|
||||
<button @onclick='() => SelectTab("data")'>Dati e disegni</button>
|
||||
<button @onclick='() => SelectTab("projects")'>Progetti</button>
|
||||
<button @onclick='() => SelectTab("save")'>Esporta</button>
|
||||
<button @onclick='() => SelectTab("tools")'>Strumenti avanzati</button>
|
||||
<button @onclick='() => SelectTab("settings")'>Impostazioni</button>
|
||||
<button @onclick='() => SelectTab("map")'>@L("menu.map")</button>
|
||||
<button @onclick='() => SelectTab("search")'>@L("menu.search")</button>
|
||||
<button @onclick='() => SelectTab("cadastre")'>@L("menu.cadastre")</button>
|
||||
<button @onclick='() => SelectTab("data")'>@L("menu.data")</button>
|
||||
<button @onclick='() => SelectTab("projects")'>@L("menu.projects")</button>
|
||||
<button @onclick='() => SelectTab("save")'>@L("menu.export")</button>
|
||||
<button @onclick='() => SelectTab("tools")'>@L("menu.tools")</button>
|
||||
<button @onclick='() => SelectTab("settings")'>@L("menu.settings")</button>
|
||||
<div class="menu-credits"><strong>LME Mappe Catastali</strong><span>Autore: Livio Merola</span><a href="mailto:info@liviomerola.net">info@liviomerola.net</a><span>Mappe: Esri, OpenStreetMap, CARTO, OpenTopoMap. Catasto: Agenzia delle Entrate CC BY 4.0.</span></div>
|
||||
</nav>
|
||||
}
|
||||
@@ -219,7 +221,7 @@
|
||||
<input type="checkbox" @bind="ShowImportedSheetLayer" @bind:after="ApplyImportedSheetLayerVisibility" />
|
||||
<span>Mostra cornice/layer del foglio importato</span>
|
||||
</label>
|
||||
<small class="field-help">La cornice grande resta separata dalle particelle e puo essere nascosta anche nei progetti salvati.</small>
|
||||
<small class="field-help">Carica dal servizio ufficiale una cornice distinta per ogni foglio delle particelle; le cornici restano separate, trasferibili e salvabili nel progetto.</small>
|
||||
<label class="toggle-line project-save-option">
|
||||
<input type="checkbox" @bind="ShowImportedMeasurements" @bind:after="ApplyImportedMeasurementsVisibility" />
|
||||
<span>Mostra misure dei file caricati</span>
|
||||
@@ -307,9 +309,13 @@
|
||||
<span>Traccia GPS registrata</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="primary-command" @onclick="ExportMapFile" disabled="@(ExportBusy || string.IsNullOrWhiteSpace(ExportFileName))">
|
||||
<button class="primary-command" @onclick="ExportMapFile" disabled="@(ExportBusy || string.IsNullOrWhiteSpace(ExportFileName) || !CanUse(AppFeatures.Export))">
|
||||
@(ExportBusy ? "Preparazione file..." : "Esporta e salva file")
|
||||
</button>
|
||||
@if (!CanUse(AppFeatures.Export))
|
||||
{
|
||||
<small class="field-help">@ProUpgradeText</small>
|
||||
}
|
||||
<small class="field-help">KML e KMZ sono compatibili con Google Earth. Gli sfondi satellitari e il WMS non vengono incorporati nel file vettoriale.</small>
|
||||
</section>
|
||||
|
||||
@@ -329,7 +335,11 @@
|
||||
<input type="checkbox" @bind="IncludeCadastralParcels" @bind:after="SaveFlagPreferences" />
|
||||
<span>Includi le particelle catastali evidenziate</span>
|
||||
</label>
|
||||
<button class="primary-command" @onclick="SaveProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(NewProjectName))">Salva progetto</button>
|
||||
<button class="primary-command" @onclick="SaveProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(NewProjectName) || !CanUse(AppFeatures.ProjectArchive))">Salva progetto</button>
|
||||
@if (!CanUse(AppFeatures.ProjectArchive))
|
||||
{
|
||||
<small class="field-help">@ProUpgradeText</small>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
@@ -353,12 +363,12 @@
|
||||
<input @bind="ProjectFolder" placeholder="Es. Cantieri 2026" maxlength="60" />
|
||||
</label>
|
||||
<div class="project-command-stack">
|
||||
<button class="secondary-command" @onclick="UpdateProjectFolder" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Sposta</button>
|
||||
<button class="secondary-command" @onclick="RenameProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId) || SelectedProject?.IsReadOnly == true)">Rinomina</button>
|
||||
<button class="secondary-command" @onclick="DuplicateProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Duplica</button>
|
||||
<button class="secondary-command" @onclick="ToggleProjectArchive" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">@(SelectedProject?.IsArchived == true ? "Ripristina" : "Archivia")</button>
|
||||
<button class="secondary-command" @onclick='() => ShareProject(false)' disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Condividi modificabile</button>
|
||||
<button class="secondary-command" @onclick='() => ShareProject(true)' disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Condividi in sola lettura</button>
|
||||
<button class="secondary-command" @onclick="UpdateProjectFolder" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId) || !CanUse(AppFeatures.ProjectArchive))">Sposta</button>
|
||||
<button class="secondary-command" @onclick="RenameProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId) || SelectedProject?.IsReadOnly == true || !CanUse(AppFeatures.ProjectArchive))">Rinomina</button>
|
||||
<button class="secondary-command" @onclick="DuplicateProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId) || !CanUse(AppFeatures.ProjectArchive))">Duplica</button>
|
||||
<button class="secondary-command" @onclick="ToggleProjectArchive" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId) || !CanUse(AppFeatures.ProjectArchive))">@(SelectedProject?.IsArchived == true ? "Ripristina" : "Archivia")</button>
|
||||
<button class="secondary-command" @onclick='() => ShareProject(false)' disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId) || !CanUse(AppFeatures.ProjectArchive))">Condividi modificabile</button>
|
||||
<button class="secondary-command" @onclick='() => ShareProject(true)' disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId) || !CanUse(AppFeatures.ProjectArchive))">Condividi in sola lettura</button>
|
||||
</div>
|
||||
@if (SelectedProject?.IsReadOnly == true)
|
||||
{
|
||||
@@ -374,6 +384,14 @@
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (!string.IsNullOrWhiteSpace(SelectedProjectId) && ProjectVersions.Count == 0)
|
||||
{
|
||||
<small class="field-help">Non ci sono ancora revisioni: la prima viene creata quando salvi nuovamente un progetto già esistente.</small>
|
||||
}
|
||||
else if (ProjectVersions.Count > 0)
|
||||
{
|
||||
<small class="field-help">@ProjectVersions.Count revisioni disponibili, ordinate dalla più recente.</small>
|
||||
}
|
||||
<div class="button-row">
|
||||
<button class="secondary-command" @onclick="LoadProjectVersion" disabled="@(string.IsNullOrWhiteSpace(SelectedVersionId))">Apri revisione</button>
|
||||
<button class="secondary-command" @onclick="CompareProjectVersion" disabled="@(string.IsNullOrWhiteSpace(SelectedVersionId))">Confronta</button>
|
||||
@@ -384,10 +402,10 @@
|
||||
<section class="panel-section">
|
||||
<h2>Backup e trasferimento</h2>
|
||||
<div class="button-row">
|
||||
<button class="secondary-command" @onclick="ShareFullBackup">Crea backup</button>
|
||||
<button class="secondary-command" @onclick="RestoreBackup">Ripristina backup</button>
|
||||
<button class="secondary-command" @onclick="ShareFullBackup" disabled="@(!CanUse(AppFeatures.ProjectArchive))">Crea backup</button>
|
||||
<button class="secondary-command" @onclick="RestoreBackup" disabled="@(!CanUse(AppFeatures.ProjectArchive))">Ripristina backup</button>
|
||||
</div>
|
||||
<button class="secondary-command" @onclick="ImportProject">Importa progetto condiviso</button>
|
||||
<button class="secondary-command" @onclick="ImportProject" disabled="@(!CanUse(AppFeatures.ProjectArchive))">Importa progetto condiviso</button>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
@@ -435,7 +453,7 @@
|
||||
Livelli di zoom aggiuntivi
|
||||
<input type="number" min="0" max="4" @bind="OfflineExtraZoomLevels" />
|
||||
</label>
|
||||
<button class="primary-command" @onclick="DownloadOfflineArea" disabled="@OfflineBusy">@(OfflineBusy ? "Download in corso..." : "Scarica area visibile")</button>
|
||||
<button class="primary-command" @onclick="DownloadOfflineArea" disabled="@(OfflineBusy || !CanUse(AppFeatures.OfflineMaps))">@(OfflineBusy ? "Download in corso..." : "Scarica area visibile")</button>
|
||||
<div class="button-row">
|
||||
<button class="secondary-command" @onclick="UseOfflineMap">Usa mappe offline</button>
|
||||
<button class="danger-command" @onclick="ClearOfflineArea">Svuota archivio</button>
|
||||
@@ -448,6 +466,66 @@
|
||||
else if (ActiveTab == "settings")
|
||||
{
|
||||
<section class="panel-content">
|
||||
<section class="panel-section">
|
||||
<h2>@L("settings.localization.title")</h2>
|
||||
<label>
|
||||
@L("settings.language")
|
||||
<select @bind="LanguagePreference" @bind:after="ApplyLanguagePreference">
|
||||
<option value="auto">@L("settings.language.auto")</option>
|
||||
<option value="it">@L("settings.language.it")</option>
|
||||
<option value="en">@L("settings.language.en")</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@L("settings.measurements")
|
||||
<select @bind="MeasurementPreference" @bind:after="ApplyMeasurementPreference">
|
||||
<option value="auto">@L("settings.measurements.auto")</option>
|
||||
<option value="metric">@L("settings.measurements.metric")</option>
|
||||
<option value="imperial">@L("settings.measurements.imperial")</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>@L("settings.pro.title")</h2>
|
||||
<div class="service-note">
|
||||
<strong>@L("settings.pro.status"): @Entitlements.PlanLabel</strong>
|
||||
<p>@L("settings.pro.note")</p>
|
||||
<p>@L("settings.pro.offline")</p>
|
||||
<div class="button-row">
|
||||
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.MonthlyBasePlanId)' disabled="@BillingBusy">@L("settings.pro.monthly")</button>
|
||||
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.YearlyBasePlanId)' disabled="@BillingBusy">@L("settings.pro.yearly")</button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="secondary-command" @onclick="RestorePurchases" disabled="@BillingBusy">@L("settings.pro.restore")</button>
|
||||
<button class="secondary-command" @onclick="OpenSubscriptionManagement">@L("settings.pro.manage")</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
@L("settings.pro.manual.code")
|
||||
<textarea class="license-code-input" @bind="ManualLicenseCode" rows="5" spellcheck="false" autocomplete="off" placeholder="LME1..."></textarea>
|
||||
</label>
|
||||
<small class="field-help">@L("settings.pro.manual.help")</small>
|
||||
<div class="button-row">
|
||||
<button class="primary-command" @onclick="ActivateManualLicense" disabled="@string.IsNullOrWhiteSpace(ManualLicenseCode)">@L("settings.pro.manual.activate")</button>
|
||||
<button class="secondary-command" @onclick="RemoveManualLicense" disabled="@(Entitlements.ManualLicense is null)">@L("settings.pro.manual.remove")</button>
|
||||
</div>
|
||||
@if (Entitlements.ManualLicense is { } manualLicense)
|
||||
{
|
||||
<div class="service-note">
|
||||
<strong>@manualLicense.HolderName</strong>
|
||||
<p>@manualLicense.Email</p>
|
||||
<p>@L("settings.pro.manual.expires"): @manualLicense.ExpiresAtUtc.ToLocalTime().ToString("g", L10n.Culture)</p>
|
||||
<p>@(Entitlements.HasManualProAccess ? L("settings.pro.manual.valid") : L("settings.pro.manual.expired"))</p>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(ManualLicenseMessage))
|
||||
{
|
||||
<small class="field-help">@ManualLicenseMessage</small>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>Mappa e sfondo</h2>
|
||||
<label>
|
||||
@@ -538,8 +616,8 @@
|
||||
</label>
|
||||
<button class="secondary-command" @onclick="SaveWebDavSettings">Salva configurazione</button>
|
||||
<div class="button-row">
|
||||
<button class="primary-command" @onclick="SyncUpload" disabled="@SyncBusy">Invia al cloud</button>
|
||||
<button class="secondary-command" @onclick="SyncDownload" disabled="@SyncBusy">Scarica dal cloud</button>
|
||||
<button class="primary-command" @onclick="SyncUpload" disabled="@(SyncBusy || !CanUse(AppFeatures.CloudSync))">Invia al cloud</button>
|
||||
<button class="secondary-command" @onclick="SyncDownload" disabled="@(SyncBusy || !CanUse(AppFeatures.CloudSync))">Scarica dal cloud</button>
|
||||
</div>
|
||||
<small class="field-help">@SyncStatus</small>
|
||||
</section>
|
||||
@@ -565,7 +643,7 @@
|
||||
<span>Mostra punto e cerchio GPS</span>
|
||||
</label>
|
||||
<label class="toggle-line project-save-option">
|
||||
<input type="checkbox" @bind="TrailEnabled" @bind:after="ApplyTrailOption" />
|
||||
<input type="checkbox" @bind="TrailEnabled" @bind:after="ApplyTrailOption" disabled="@(!CanUse(AppFeatures.GpsTrail))" />
|
||||
<span>Registra la traccia mentre cammini</span>
|
||||
</label>
|
||||
<div class="button-row">
|
||||
@@ -636,6 +714,11 @@
|
||||
private string ProjectArchiveMessage { get; set; } = "Nessun progetto salvato.";
|
||||
private string ProjectFolder { get; set; } = string.Empty;
|
||||
private string SelectedVersionId { get; set; } = string.Empty;
|
||||
private string LanguagePreference { get; set; } = "auto";
|
||||
private string MeasurementPreference { get; set; } = "auto";
|
||||
private string ManualLicenseCode { get; set; } = string.Empty;
|
||||
private string ManualLicenseMessage { get; set; } = string.Empty;
|
||||
private bool BillingBusy { get; set; }
|
||||
private string Status { get; set; } = "online";
|
||||
private string SelectedBaseLayer { get; set; } = "satellite";
|
||||
private string GoogleMapsApiKey { get; set; } = string.Empty;
|
||||
@@ -692,6 +775,7 @@
|
||||
private string UpdateStatus { get; set; } = $"Versione installata: {AppUpdateService.CurrentVersion}";
|
||||
private string UpdateDownloadUrl { get; set; } = string.Empty;
|
||||
private string UpdateReleaseNotes { get; set; } = string.Empty;
|
||||
private string ProUpgradeText => "Funzione inclusa in LME Pro. I dati esistenti restano consultabili.";
|
||||
private CancellationTokenSource? CatalogCancellation { get; set; }
|
||||
private CancellationTokenSource? GpsLookupCancellation { get; set; }
|
||||
private CancellationTokenSource? ProjectAutoSaveCancellation { get; set; }
|
||||
@@ -719,7 +803,7 @@
|
||||
"projects" => "Archivio progetti",
|
||||
"save" => "Salvataggio ed esportazione",
|
||||
"tools" => "Strumenti avanzati",
|
||||
"settings" => "Impostazioni",
|
||||
"settings" => L("panel.settings.title"),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
@@ -730,7 +814,7 @@
|
||||
"projects" => "Salva, apri e visualizza i tuoi lavori",
|
||||
"save" => "Esporta file GIS",
|
||||
"tools" => "Oggetti, geometrie, coordinate e offline",
|
||||
"settings" => "Mappa, GPS e servizi ufficiali",
|
||||
"settings" => L("panel.settings.eyebrow"),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
@@ -746,6 +830,11 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
L10n.Load();
|
||||
L10n.ApplyCulture();
|
||||
await Entitlements.LoadAsync();
|
||||
LanguagePreference = L10n.LanguagePreference;
|
||||
MeasurementPreference = L10n.MeasurementPreference;
|
||||
LoadFlagPreferences();
|
||||
|
||||
try
|
||||
@@ -789,6 +878,8 @@
|
||||
{
|
||||
MapCallbackReference = DotNetObjectReference.Create(this);
|
||||
await JS.InvokeVoidAsync("lmeMap.init", MapCallbackReference);
|
||||
await ApplyLocalizationToMapAsync();
|
||||
await ApplyEntitlementsToMapAsync();
|
||||
await ApplyFlagPreferencesToMapAsync();
|
||||
await ApplyAdvancedOptions();
|
||||
await CenterOnInitialLocationAsync();
|
||||
@@ -905,6 +996,7 @@
|
||||
await JS.InvokeVoidAsync("lmeMap.clearLegacyProjectSnapshot");
|
||||
await RefreshProjectsAsync();
|
||||
SelectedProjectId = migrated.Id;
|
||||
await RefreshProjectVersionsAsync(true);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch
|
||||
@@ -1299,6 +1391,10 @@
|
||||
return InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task<string> GetOfficialCadastralSheetsNear(double latitude, double longitude) =>
|
||||
OfficialCadastreService.GetSheetsNearAsync(latitude, longitude);
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnMapChanged(string action)
|
||||
{
|
||||
@@ -1362,6 +1458,7 @@
|
||||
{
|
||||
SelectedProjectId = string.Empty;
|
||||
}
|
||||
await RefreshProjectVersionsAsync();
|
||||
ProjectArchiveMessage = Projects.Count == 0
|
||||
? "Nessun progetto salvato."
|
||||
: $"{Projects.Count} {(Projects.Count == 1 ? "progetto salvato" : "progetti salvati")} nell'archivio interno.";
|
||||
@@ -1376,10 +1473,18 @@
|
||||
{
|
||||
SelectedProjectId = args.Value?.ToString() ?? string.Empty;
|
||||
ProjectFolder = SelectedProject?.Folder ?? string.Empty;
|
||||
SelectedVersionId = string.Empty;
|
||||
await RefreshProjectVersionsAsync(true);
|
||||
}
|
||||
|
||||
private async Task RefreshProjectVersionsAsync(bool resetSelection = false)
|
||||
{
|
||||
ProjectVersions = string.IsNullOrWhiteSpace(SelectedProjectId)
|
||||
? []
|
||||
: await ProjectStorageService.GetVersionsAsync(SelectedProjectId);
|
||||
if (resetSelection || ProjectVersions.All(version => version.Id != SelectedVersionId))
|
||||
{
|
||||
SelectedVersionId = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private Task UndoMapChange() => JS.InvokeVoidAsync("lmeMap.undoMapChange").AsTask();
|
||||
@@ -1396,16 +1501,19 @@
|
||||
|
||||
private async Task DuplicateProject()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
if (SelectedProject is null) return;
|
||||
var copy = await ProjectStorageService.DuplicateAsync(SelectedProject.Id);
|
||||
await RefreshProjectsAsync();
|
||||
SelectedProjectId = copy.Id;
|
||||
ProjectFolder = copy.Folder;
|
||||
await RefreshProjectVersionsAsync(true);
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto duplicato: {copy.Name}.");
|
||||
}
|
||||
|
||||
private async Task RenameProject()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
if (SelectedProject is not { } project) return;
|
||||
var requestedName = await JS.InvokeAsync<string?>("lmeMap.promptProjectName", project.Name);
|
||||
if (string.IsNullOrWhiteSpace(requestedName)
|
||||
@@ -1420,6 +1528,7 @@
|
||||
var renamed = await ProjectStorageService.RenameAsync(project.Id, requestedName);
|
||||
await RefreshProjectsAsync();
|
||||
SelectedProjectId = renamed.Id;
|
||||
await RefreshProjectVersionsAsync();
|
||||
NewProjectName = renamed.Name;
|
||||
if (!HasUnsavedChanges)
|
||||
{
|
||||
@@ -1439,6 +1548,7 @@
|
||||
|
||||
private async Task UpdateProjectFolder()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
if (SelectedProject is null) return;
|
||||
await ProjectStorageService.UpdateOrganizationAsync(SelectedProject.Id, ProjectFolder);
|
||||
await RefreshProjectsAsync();
|
||||
@@ -1447,6 +1557,7 @@
|
||||
|
||||
private async Task ToggleProjectArchive()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
if (SelectedProject is not { } project) return;
|
||||
await ProjectStorageService.UpdateOrganizationAsync(project.Id, isArchived: !project.IsArchived);
|
||||
await RefreshProjectsAsync();
|
||||
@@ -1455,6 +1566,7 @@
|
||||
|
||||
private async Task ShareProject(bool readOnly)
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
if (SelectedProject is null) return;
|
||||
var path = await ProjectStorageService.CreateProjectPackageAsync(SelectedProject.Id, readOnly);
|
||||
await Share.Default.RequestAsync(new ShareFileRequest { Title = readOnly ? "Condividi progetto LME in sola lettura" : "Condividi progetto LME modificabile", File = new ShareFile(path, "application/json") });
|
||||
@@ -1462,12 +1574,14 @@
|
||||
|
||||
private async Task ShareFullBackup()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
var path = await ProjectStorageService.CreateBackupAsync();
|
||||
await Share.Default.RequestAsync(new ShareFileRequest { Title = "Backup completo LME Mappe Catastali", File = new ShareFile(path, "application/zip") });
|
||||
}
|
||||
|
||||
private async Task RestoreBackup()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli backup LME" });
|
||||
if (file is null) return;
|
||||
var path = await CopyPickedFileToCacheAsync(file);
|
||||
@@ -1478,12 +1592,14 @@
|
||||
|
||||
private async Task ImportProject()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli progetto LME" });
|
||||
if (file is null) return;
|
||||
var path = await CopyPickedFileToCacheAsync(file);
|
||||
var imported = await ProjectStorageService.ImportProjectAsync(path);
|
||||
await RefreshProjectsAsync();
|
||||
SelectedProjectId = imported.Id;
|
||||
await RefreshProjectVersionsAsync(true);
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto importato: {imported.Name}.");
|
||||
}
|
||||
|
||||
@@ -1516,6 +1632,12 @@
|
||||
|
||||
private async Task ExportMapFile()
|
||||
{
|
||||
if (!CanUse(AppFeatures.Export))
|
||||
{
|
||||
await ShowProRequiredAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ExportImported && !ExportImportedSheets && !ExportCadastral
|
||||
&& !ExportDrawings && !ExportPoi && !ExportTrail)
|
||||
{
|
||||
@@ -1558,6 +1680,12 @@
|
||||
|
||||
private async Task SaveProject()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive))
|
||||
{
|
||||
await ShowProRequiredAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewProjectName.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
@@ -1584,6 +1712,7 @@
|
||||
existing?.Id);
|
||||
await RefreshProjectsAsync();
|
||||
SelectedProjectId = saved.Id;
|
||||
await RefreshProjectVersionsAsync();
|
||||
NewProjectName = saved.Name;
|
||||
await JS.InvokeVoidAsync("lmeMap.setProjectReadOnly", false);
|
||||
await JS.InvokeVoidAsync("lmeMap.markProjectSaved");
|
||||
@@ -1705,6 +1834,12 @@
|
||||
|
||||
private async Task DownloadOfflineArea()
|
||||
{
|
||||
if (!CanUse(AppFeatures.OfflineMaps))
|
||||
{
|
||||
await ShowProRequiredAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
OfflineBusy = true;
|
||||
try
|
||||
{
|
||||
@@ -1739,6 +1874,12 @@
|
||||
|
||||
private async Task SyncUpload()
|
||||
{
|
||||
if (!CanUse(AppFeatures.CloudSync))
|
||||
{
|
||||
await ShowProRequiredAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
SyncBusy = true;
|
||||
try
|
||||
{
|
||||
@@ -1753,6 +1894,12 @@
|
||||
|
||||
private async Task SyncDownload()
|
||||
{
|
||||
if (!CanUse(AppFeatures.CloudSync))
|
||||
{
|
||||
await ShowProRequiredAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await JS.InvokeAsync<bool>("lmeMap.confirmDiscardChanges", "ripristinare l'archivio cloud")) return;
|
||||
SyncBusy = true;
|
||||
try
|
||||
@@ -1803,8 +1950,115 @@
|
||||
}
|
||||
|
||||
private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask();
|
||||
|
||||
private string L(string key) => L10n.T(key);
|
||||
|
||||
private bool CanUse(string feature) => Entitlements.CanUse(feature);
|
||||
|
||||
private Task ShowProRequiredAsync() => JS.InvokeVoidAsync("lmeMap.showInfo", ProUpgradeText).AsTask();
|
||||
|
||||
private async Task ApplyLanguagePreference()
|
||||
{
|
||||
L10n.SetLanguage(LanguagePreference);
|
||||
LanguagePreference = L10n.LanguagePreference;
|
||||
await ApplyLocalizationToMapAsync();
|
||||
}
|
||||
|
||||
private async Task ApplyMeasurementPreference()
|
||||
{
|
||||
L10n.SetMeasurementSystem(MeasurementPreference);
|
||||
MeasurementPreference = L10n.MeasurementPreference;
|
||||
await ApplyLocalizationToMapAsync();
|
||||
}
|
||||
|
||||
private Task ApplyLocalizationToMapAsync() => JS.InvokeVoidAsync("lmeMap.setLocalization", new
|
||||
{
|
||||
culture = L10n.Culture.Name,
|
||||
measurementSystem = L10n.MeasurementSystem,
|
||||
labels = new
|
||||
{
|
||||
measureArea = L("measure.area"),
|
||||
measureDistance = L("measure.distance"),
|
||||
measureCircle = L("measure.circle"),
|
||||
drawRectangle = L("measure.rectangle"),
|
||||
addPoi = L("measure.poi"),
|
||||
proRequired = ProUpgradeText
|
||||
}
|
||||
}).AsTask();
|
||||
|
||||
private Task ApplyEntitlementsToMapAsync() => JS.InvokeVoidAsync("lmeMap.setEntitlements", new
|
||||
{
|
||||
advancedGeometry = CanUse(AppFeatures.AdvancedGeometry)
|
||||
}).AsTask();
|
||||
|
||||
private async Task RestorePurchases()
|
||||
{
|
||||
BillingBusy = true;
|
||||
try
|
||||
{
|
||||
var result = await Entitlements.RestorePurchasesAsync();
|
||||
await ApplyEntitlementsToMapAsync();
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", result.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BillingBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubscribeGooglePro(string basePlanId)
|
||||
{
|
||||
BillingBusy = true;
|
||||
try
|
||||
{
|
||||
var result = await Entitlements.PurchaseGoogleProAsync(basePlanId);
|
||||
await ApplyEntitlementsToMapAsync();
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", result.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BillingBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ActivateManualLicense()
|
||||
{
|
||||
var result = await Entitlements.ActivateManualLicenseAsync(ManualLicenseCode);
|
||||
ManualLicenseMessage = result.IsValid
|
||||
? L("settings.pro.manual.activated")
|
||||
: result.Error ?? L("settings.pro.manual.invalid");
|
||||
if (result.IsValid)
|
||||
{
|
||||
ManualLicenseCode = string.Empty;
|
||||
}
|
||||
|
||||
await ApplyEntitlementsToMapAsync();
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", ManualLicenseMessage);
|
||||
}
|
||||
|
||||
private async Task RemoveManualLicense()
|
||||
{
|
||||
await Entitlements.RemoveManualLicenseAsync();
|
||||
ManualLicenseCode = string.Empty;
|
||||
ManualLicenseMessage = L("settings.pro.manual.removed");
|
||||
await ApplyEntitlementsToMapAsync();
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", ManualLicenseMessage);
|
||||
}
|
||||
|
||||
private Task OpenSubscriptionManagement() => Launcher.Default.OpenAsync(new Uri(
|
||||
"https://play.google.com/store/account/subscriptions"));
|
||||
|
||||
private async Task ApplyTrailOption()
|
||||
{
|
||||
if (!CanUse(AppFeatures.GpsTrail))
|
||||
{
|
||||
TrailEnabled = false;
|
||||
SaveFlagPreferences();
|
||||
await JS.InvokeVoidAsync("lmeMap.toggleTrail", false);
|
||||
await ShowProRequiredAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
SaveFlagPreferences();
|
||||
await JS.InvokeVoidAsync("lmeMap.toggleTrail", TrailEnabled);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</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 -->
|
||||
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<!-- Display name -->
|
||||
<ApplicationTitle>LME Mappe Catastali</ApplicationTitle>
|
||||
<ApplicationTitle>LME Mappe Catastali Pro</ApplicationTitle>
|
||||
|
||||
<!-- App Identifier -->
|
||||
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
|
||||
<ApplicationId>it.liviomerola.lmemappecatastali.pro</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>0.4.5</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>40</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>100</ApplicationVersion>
|
||||
<Version>$(ApplicationDisplayVersion)</Version>
|
||||
|
||||
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||
@@ -43,6 +43,18 @@
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
|
||||
</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>
|
||||
<!-- App Icon -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#075E54" />
|
||||
@@ -64,8 +76,23 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Controls" 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" />
|
||||
</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>
|
||||
@@ -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.
|
||||
@@ -26,6 +26,14 @@ public static class MauiProgram
|
||||
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
|
||||
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,8 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
</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;
|
||||
}
|
||||
@@ -2,6 +2,29 @@
|
||||
|
||||
App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS.
|
||||
|
||||
## Versione 0.4.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.
|
||||
@@ -144,13 +167,13 @@ Per Android serve Android SDK installato e configurato in Visual Studio.
|
||||
## Build da terminale
|
||||
|
||||
```powershell
|
||||
dotnet build -f net9.0-windows10.0.19041.0
|
||||
dotnet build -f net10.0-windows10.0.19041.0
|
||||
```
|
||||
|
||||
Per Android:
|
||||
|
||||
```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
|
||||
@@ -158,11 +181,11 @@ dotnet build -f net9.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Loc
|
||||
La pubblicazione Android genera:
|
||||
|
||||
```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:
|
||||
|
||||
```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
|
||||
```
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public sealed class MapExportService
|
||||
{
|
||||
var root = new XElement("gpx",
|
||||
new XAttribute("version", "1.1"),
|
||||
new XAttribute("creator", "LME Mappe Catastali 0.4.5"),
|
||||
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())
|
||||
|
||||
@@ -22,6 +22,34 @@ public sealed class OfficialCadastreService
|
||||
double longitude,
|
||||
double radiusMeters,
|
||||
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 deltaLatitude = meters / 111_320d;
|
||||
@@ -40,11 +68,11 @@ public sealed class OfficialCadastreService
|
||||
["language"] = "ita",
|
||||
["SERVICE"] = "WFS",
|
||||
["VERSION"] = "2.0.0",
|
||||
["TYPENAMES"] = "CP:CadastralParcel",
|
||||
["TYPENAMES"] = typeName,
|
||||
["SRSNAME"] = "urn:ogc:def:crs:EPSG::6706",
|
||||
["BBOX"] = bbox,
|
||||
["REQUEST"] = "GetFeature",
|
||||
["COUNT"] = "100"
|
||||
["COUNT"] = count.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var url = WfsEndpoint + "?" + string.Join("&", query.Select(item =>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.401",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+23
-4
@@ -31,7 +31,8 @@ body {
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -54,16 +55,34 @@ button:disabled {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid #bfcbd2;
|
||||
border-radius: 6px;
|
||||
padding: 0 11px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
min-height: 44px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 9px 11px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.license-code-input {
|
||||
min-height: 108px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
input[type="range"],
|
||||
input[type="checkbox"] {
|
||||
min-height: auto;
|
||||
|
||||
+254
-85
@@ -92,7 +92,20 @@ const lmeState = {
|
||||
changeNotifyTimer: null,
|
||||
offlineDbPromise: null,
|
||||
offlineTileCount: 0,
|
||||
dotNetReference: null
|
||||
dotNetReference: null,
|
||||
culture: "it-IT",
|
||||
measurementSystem: "metric",
|
||||
entitlements: {
|
||||
advancedGeometry: false
|
||||
},
|
||||
labels: {
|
||||
measureArea: "Misura area",
|
||||
measureDistance: "Misura distanza",
|
||||
measureCircle: "Misura cerchio",
|
||||
drawRectangle: "Disegna rettangolo",
|
||||
addPoi: "Aggiungi POI",
|
||||
proRequired: "Funzione inclusa in LME Pro."
|
||||
}
|
||||
};
|
||||
|
||||
// AdE publishes the cadastral WMS in ETRS89. WMS 1.1.1 keeps the longitude,
|
||||
@@ -167,9 +180,38 @@ window.lmeMap = {
|
||||
clearOfflineTiles,
|
||||
getOfflineTileStats,
|
||||
resize,
|
||||
setLocalization,
|
||||
setEntitlements,
|
||||
showInfo: setInfo
|
||||
};
|
||||
|
||||
function setLocalization(options = {}) {
|
||||
lmeState.culture = options.culture || lmeState.culture || "it-IT";
|
||||
lmeState.measurementSystem = options.measurementSystem === "imperial" ? "imperial" : "metric";
|
||||
lmeState.labels = { ...lmeState.labels, ...(options.labels || {}) };
|
||||
configureDrawLabels();
|
||||
if (lmeState.drawControl) {
|
||||
const wasVisible = true;
|
||||
setDrawToolsVisible(false);
|
||||
if (wasVisible) {
|
||||
setDrawToolsVisible(true);
|
||||
}
|
||||
}
|
||||
refreshImportedMeasurements();
|
||||
refreshDrawingMeasurements();
|
||||
refreshLayerManager();
|
||||
}
|
||||
|
||||
function setEntitlements(options = {}) {
|
||||
lmeState.entitlements = { ...lmeState.entitlements, ...options };
|
||||
}
|
||||
|
||||
function requireProFeature(feature) {
|
||||
if (lmeState.entitlements?.[feature]) return true;
|
||||
setInfo(lmeState.labels.proRequired || "Funzione inclusa in LME Pro.");
|
||||
return false;
|
||||
}
|
||||
|
||||
function init(dotNetReference) {
|
||||
if (dotNetReference) {
|
||||
lmeState.dotNetReference = dotNetReference;
|
||||
@@ -2019,9 +2061,15 @@ function renderLayerSelectionToolbar() {
|
||||
</div>`;
|
||||
const search = toolbar.querySelector("input");
|
||||
search.addEventListener("input", () => {
|
||||
const selectionStart = search.selectionStart ?? search.value.length;
|
||||
const selectionEnd = search.selectionEnd ?? selectionStart;
|
||||
lmeState.managerSearchText = search.value;
|
||||
refreshLayerManager();
|
||||
requestAnimationFrame(() => document.querySelector(".layer-selection-toolbar input")?.focus());
|
||||
const replacement = document.querySelector(".layer-selection-toolbar input");
|
||||
if (replacement) {
|
||||
replacement.focus({ preventScroll: true });
|
||||
replacement.setSelectionRange(selectionStart, selectionEnd);
|
||||
}
|
||||
});
|
||||
toolbar.querySelectorAll("button").forEach(button => button.addEventListener("click", () => {
|
||||
const records = [...lmeState.managerSelectedObjectIds]
|
||||
@@ -2628,6 +2676,7 @@ async function moveManagedRecords(records) {
|
||||
}
|
||||
|
||||
async function bufferManagedRecords(records) {
|
||||
if (!requireProFeature("advancedGeometry")) return;
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
const result = await showFormDialog("Crea buffer", [{ name: "meters", label: "Distanza in metri", type: "number", value: "5", step: "0.01" }], "Crea buffer");
|
||||
if (!result) return;
|
||||
@@ -2645,6 +2694,7 @@ async function bufferManagedRecords(records) {
|
||||
}
|
||||
|
||||
function unionManagedRecords(records) {
|
||||
if (!requireProFeature("advancedGeometry")) return;
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
const polygons = records.filter(record => record.layer instanceof L.Polygon).map(record => record.layer.toGeoJSON());
|
||||
if (polygons.length < 2) { setInfo("Per unire seleziona almeno due aree o poligoni."); return; }
|
||||
@@ -2661,6 +2711,7 @@ function unionManagedRecords(records) {
|
||||
}
|
||||
|
||||
function splitManagedRecords(records) {
|
||||
if (!requireProFeature("advancedGeometry")) return;
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
const polygonRecord = records.find(record => record.layer instanceof L.Polygon);
|
||||
const lineRecord = records.find(record => record.layer instanceof L.Polyline && !(record.layer instanceof L.Polygon));
|
||||
@@ -2690,6 +2741,7 @@ function selectedManagedRecords() {
|
||||
}
|
||||
|
||||
function createHoleFromSelected() {
|
||||
if (!requireProFeature("advancedGeometry")) return false;
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||||
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
|
||||
if (polygons.length !== 2) { showGeometryResult("Creazione foro", "Seleziona esattamente due poligoni: l'area esterna e l'area interna da escludere.", "warning"); return false; }
|
||||
@@ -2712,6 +2764,7 @@ function createHoleFromSelected() {
|
||||
}
|
||||
|
||||
function validateAndRepairSelected() {
|
||||
if (!requireProFeature("advancedGeometry")) return "LME Pro richiesto";
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return "Progetto in sola lettura"; }
|
||||
const records = selectedManagedRecords();
|
||||
if (!records.length) { showGeometryResult("Controlla e ripara", "Seleziona uno o più oggetti dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return "Nessuna selezione"; }
|
||||
@@ -2744,6 +2797,7 @@ function validateAndRepairSelected() {
|
||||
}
|
||||
|
||||
function compareSelectedGeometries() {
|
||||
if (!requireProFeature("advancedGeometry")) return false;
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||||
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
|
||||
if (polygons.length !== 2) { showGeometryResult("Confronta due confini", "Seleziona esattamente due poligoni dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return false; }
|
||||
@@ -3461,77 +3515,155 @@ function applyManagedVisualOrder(container) {
|
||||
});
|
||||
}
|
||||
|
||||
function setImportedSheetLayerVisible(enabled) {
|
||||
async function setImportedSheetLayerVisible(enabled) {
|
||||
migrateMisclassifiedImportedSheets();
|
||||
lmeState.importedSheetsVisible = Boolean(enabled);
|
||||
const generated = lmeState.importedSheetsVisible && ensureImportedSheetOutline();
|
||||
let sheetResult = null;
|
||||
if (lmeState.importedSheetsVisible) {
|
||||
setInfo("Carico i fogli catastali delle particelle...");
|
||||
sheetResult = await ensureImportedSheetOutlines();
|
||||
}
|
||||
applyImportedSheetVisibility();
|
||||
const count = lmeState.importedSheets?.toGeoJSON()?.features?.length || 0;
|
||||
setInfo(lmeState.importedSheetsVisible
|
||||
? (count
|
||||
? `${generated ? "Cornice generata dai dati importati" : "Layer del foglio importato visibile"} (${count} elementi).`
|
||||
: "Nessun dato importato da cui ricavare la cornice del foglio.")
|
||||
? `${sheetResult?.added ? "Fogli catastali ufficiali caricati" : "Layer dei fogli catastali visibile"} (${count} cornici).`
|
||||
: (sheetResult?.failed
|
||||
? "Il servizio ufficiale dei fogli catastali non e' al momento disponibile. Riprova mostrando nuovamente il layer."
|
||||
: "Nessuna particella con cui individuare il foglio catastale."))
|
||||
: "Layer del foglio importato nascosto; le particelle restano visibili.");
|
||||
}
|
||||
|
||||
function ensureImportedSheetOutline() {
|
||||
if (!lmeState.imported || !lmeState.importedSheets) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentSheets = lmeState.importedSheets.toGeoJSON();
|
||||
const hasAreaOutline = (currentSheets.features || []).some(feature =>
|
||||
["Polygon", "MultiPolygon"].includes(feature?.geometry?.type));
|
||||
if (hasAreaOutline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imported = lmeState.imported.toGeoJSON();
|
||||
const points = collectGeoJsonCoordinates(imported);
|
||||
if (!points.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bounds = points.reduce((result, point) => ({
|
||||
west: Math.min(result.west, point[0]),
|
||||
south: Math.min(result.south, point[1]),
|
||||
east: Math.max(result.east, point[0]),
|
||||
north: Math.max(result.north, point[1])
|
||||
}), { west: Infinity, south: Infinity, east: -Infinity, north: -Infinity });
|
||||
if (!Number.isFinite(bounds.west)
|
||||
|| !Number.isFinite(bounds.south)
|
||||
|| bounds.east <= bounds.west
|
||||
|| bounds.north <= bounds.south) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const longitudePadding = Math.max((bounds.east - bounds.west) * 0.01, 0.000001);
|
||||
const latitudePadding = Math.max((bounds.north - bounds.south) * 0.01, 0.000001);
|
||||
const west = bounds.west - longitudePadding;
|
||||
const south = bounds.south - latitudePadding;
|
||||
const east = bounds.east + longitudePadding;
|
||||
const north = bounds.north + latitudePadding;
|
||||
addImportedSheetFeatures({
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
properties: {
|
||||
name: "Cornice del foglio importato",
|
||||
_lmeGeneratedSheetOutline: true
|
||||
},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [[
|
||||
[west, south],
|
||||
[east, south],
|
||||
[east, north],
|
||||
[west, north],
|
||||
[west, south]
|
||||
]]
|
||||
}
|
||||
}]
|
||||
function removeLegacyGeneratedSheetOutlines() {
|
||||
const category = layerManagerCategories().find(item => item.key === "importedSheets");
|
||||
if (!category) return 0;
|
||||
const generated = managedRecordsForCategory(category).filter(record =>
|
||||
Boolean(record.layer?.feature?.properties?._lmeGeneratedSheetOutline));
|
||||
generated.forEach(record => {
|
||||
record.parent?.removeLayer?.(record.layer);
|
||||
clearManagedObjectState(record.id);
|
||||
});
|
||||
return true;
|
||||
return generated.length;
|
||||
}
|
||||
|
||||
function cadastralSheetIdentity(feature, index) {
|
||||
const properties = feature?.properties || {};
|
||||
const explicit = properties.foglio ?? properties.FOGLIO ?? properties.sheet ?? properties.SHEET;
|
||||
const administrativeUnit = String(properties.ADMINISTRATIVEUNIT || "").trim().toUpperCase();
|
||||
if (String(explicit ?? "").trim()) {
|
||||
return `${administrativeUnit}|${String(explicit).trim().replace(/^0+/, "") || "0"}`;
|
||||
}
|
||||
const context = [
|
||||
properties.NATIONALCADASTRALREFERENCE,
|
||||
properties.INSPIREID_LOCALID,
|
||||
properties._lmeParcelReference,
|
||||
properties._lmeSource,
|
||||
properties._lmeFolder,
|
||||
properties.name,
|
||||
properties.Name
|
||||
].filter(Boolean).join(" ").toUpperCase();
|
||||
const namedSheet = context.match(/\bFOGLIO\s*[_ .-]*0*(\d+[A-Z]?)\b/i);
|
||||
const codedSheet = context.match(/\b([A-Z]\d{3})[._-]([0-9]{4}[A-Z0-9]{2})(?=[._/-]|\b)/i);
|
||||
if (namedSheet) return `${administrativeUnit}|${namedSheet[1]}`;
|
||||
if (codedSheet) return `${codedSheet[1]}|${codedSheet[2]}`;
|
||||
return `geometria-${index}`;
|
||||
}
|
||||
|
||||
function sheetRepresentativePoint(feature) {
|
||||
try {
|
||||
return turf.pointOnFeature(feature).geometry.coordinates;
|
||||
} catch {
|
||||
const bounds = geoJsonBounds(feature?.geometry);
|
||||
return bounds ? boundsCenter(bounds) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function cadastralSheetFeatureKey(feature) {
|
||||
const properties = feature?.properties || {};
|
||||
return String(properties.NATIONALCADASTRALZONINGREFERENCE
|
||||
|| properties.INSPIREID_LOCALID
|
||||
|| properties.LABEL
|
||||
|| JSON.stringify(feature?.geometry || {})).trim().toUpperCase();
|
||||
}
|
||||
|
||||
async function ensureImportedSheetOutlines() {
|
||||
if (!lmeState.imported || !lmeState.importedSheets) {
|
||||
return { added: 0, failed: false };
|
||||
}
|
||||
|
||||
removeLegacyGeneratedSheetOutlines();
|
||||
const existingAreaSheets = layerManagerCategories()
|
||||
.flatMap(category => managedRecordsForCategory(category))
|
||||
.map(record => ({ categoryKey: record.categoryKey, feature: record.layer?.toGeoJSON?.() }))
|
||||
.filter(item => ["Polygon", "MultiPolygon"].includes(item.feature?.geometry?.type)
|
||||
&& !item.feature?.properties?._lmeGeneratedSheetOutline
|
||||
&& (item.categoryKey === "importedSheets"
|
||||
|| item.feature?.properties?._lmeOfficialSheet
|
||||
|| item.feature?.properties?.NATIONALCADASTRALZONINGREFERENCE
|
||||
|| isSheetOrOverlayFeature(item.feature)))
|
||||
.map(item => item.feature);
|
||||
|
||||
const parcelFeatures = [
|
||||
...(lmeState.imported.toGeoJSON()?.features || []),
|
||||
...(lmeState.selectedParcelsLayer?.toGeoJSON?.()?.features || [])
|
||||
].filter(feature => ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type));
|
||||
const representatives = new Map();
|
||||
parcelFeatures.forEach((feature, index) => {
|
||||
const identity = cadastralSheetIdentity(feature, index);
|
||||
if (!representatives.has(identity)) representatives.set(identity, feature);
|
||||
});
|
||||
if (!representatives.size || !lmeState.dotNetReference) {
|
||||
return { added: 0, failed: false };
|
||||
}
|
||||
|
||||
const existingKeys = new Set(existingAreaSheets.map(cadastralSheetFeatureKey));
|
||||
const officialSheets = new Map();
|
||||
let failures = 0;
|
||||
for (const [identity, parcelFeature] of representatives) {
|
||||
const point = sheetRepresentativePoint(parcelFeature);
|
||||
if (!point) continue;
|
||||
const existingSheet = findContainingPolygon(point, {
|
||||
type: "FeatureCollection",
|
||||
features: [...existingAreaSheets, ...officialSheets.values()]
|
||||
});
|
||||
if (existingSheet) continue;
|
||||
try {
|
||||
const xml = await lmeState.dotNetReference.invokeMethodAsync(
|
||||
"GetOfficialCadastralSheetsNear",
|
||||
point[1],
|
||||
point[0]);
|
||||
const candidates = gmlParcelsToGeoJson(xml).features || [];
|
||||
const containing = findContainingPolygon(point, { type: "FeatureCollection", features: candidates });
|
||||
const sheet = containing || candidates[0];
|
||||
if (!sheet) continue;
|
||||
const key = cadastralSheetFeatureKey(sheet);
|
||||
if (existingKeys.has(key) || officialSheets.has(key)) continue;
|
||||
const reference = String(sheet.properties?.NATIONALCADASTRALZONINGREFERENCE
|
||||
|| sheet.properties?.LABEL
|
||||
|| identity.split("|").pop()
|
||||
|| "").trim();
|
||||
sheet.properties = {
|
||||
...(sheet.properties || {}),
|
||||
name: reference ? `Foglio catastale ${reference}` : "Foglio catastale",
|
||||
foglio: reference,
|
||||
_lmeSheetIdentity: identity,
|
||||
_lmeOfficialSheet: true,
|
||||
_lmeSource: reference ? `Foglio catastale ${reference}` : "Fogli catastali AdE",
|
||||
_lmeGroupName: reference ? `Foglio ${reference}` : "Fogli catastali AdE"
|
||||
};
|
||||
officialSheets.set(key, sheet);
|
||||
} catch {
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
if (officialSheets.size) {
|
||||
addImportedSheetFeatures({ type: "FeatureCollection", features: [...officialSheets.values()] });
|
||||
applyImportedSheetVisibility();
|
||||
refreshLayerManager();
|
||||
finishMapChange(`${officialSheets.size} fogli catastali ufficiali caricati.`);
|
||||
}
|
||||
return { added: officialSheets.size, failed: failures > 0 && !officialSheets.size };
|
||||
}
|
||||
|
||||
function migrateMisclassifiedImportedSheets() {
|
||||
@@ -3663,11 +3795,11 @@ function addProjectMarker(name) {
|
||||
}
|
||||
|
||||
function configureDrawLabels() {
|
||||
L.drawLocal.draw.toolbar.buttons.polygon = "Misura area";
|
||||
L.drawLocal.draw.toolbar.buttons.polyline = "Misura distanza";
|
||||
L.drawLocal.draw.toolbar.buttons.circle = "Misura cerchio";
|
||||
L.drawLocal.draw.toolbar.buttons.rectangle = "Disegna rettangolo";
|
||||
L.drawLocal.draw.toolbar.buttons.marker = "Aggiungi POI";
|
||||
L.drawLocal.draw.toolbar.buttons.polygon = lmeState.labels.measureArea;
|
||||
L.drawLocal.draw.toolbar.buttons.polyline = lmeState.labels.measureDistance;
|
||||
L.drawLocal.draw.toolbar.buttons.circle = lmeState.labels.measureCircle;
|
||||
L.drawLocal.draw.toolbar.buttons.rectangle = lmeState.labels.drawRectangle;
|
||||
L.drawLocal.draw.toolbar.buttons.marker = lmeState.labels.addPoi;
|
||||
L.drawLocal.edit.toolbar.buttons.edit = "Scegli quali disegni modificare";
|
||||
L.drawLocal.edit.toolbar.buttons.remove = "Scegli quali disegni eliminare";
|
||||
L.drawLocal.draw.handlers.polygon.tooltip.start = "Tocca il primo vertice dell'area.";
|
||||
@@ -3758,19 +3890,19 @@ function enableDrawTools() {
|
||||
}
|
||||
},
|
||||
draw: {
|
||||
circle: { metric: true, feet: false, showRadius: true, shapeOptions: orangeArea },
|
||||
circle: { metric: usesMetricMeasurements(), feet: usesImperialMeasurements(), showRadius: true, shapeOptions: orangeArea },
|
||||
circlemarker: false,
|
||||
marker: { zIndexOffset: 0 },
|
||||
polygon: {
|
||||
metric: true,
|
||||
feet: false,
|
||||
metric: usesMetricMeasurements(),
|
||||
feet: usesImperialMeasurements(),
|
||||
showArea: true,
|
||||
allowIntersection: false,
|
||||
shapeOptions: orangeArea
|
||||
},
|
||||
polyline: {
|
||||
metric: true,
|
||||
feet: false,
|
||||
metric: usesMetricMeasurements(),
|
||||
feet: usesImperialMeasurements(),
|
||||
showLength: true,
|
||||
shapeOptions: {
|
||||
pane: "drawingsPane",
|
||||
@@ -3780,7 +3912,7 @@ function enableDrawTools() {
|
||||
opacity: 0.95
|
||||
}
|
||||
},
|
||||
rectangle: { metric: true, shapeOptions: orangeArea }
|
||||
rectangle: { metric: usesMetricMeasurements(), feet: usesImperialMeasurements(), shapeOptions: orangeArea }
|
||||
}
|
||||
});
|
||||
lmeState.map.addControl(lmeState.drawControl);
|
||||
@@ -3934,8 +4066,8 @@ function startMeasurementDrawing(mode) {
|
||||
area: () => new L.Draw.Polygon(lmeState.map, {
|
||||
allowIntersection: false,
|
||||
showArea: true,
|
||||
metric: true,
|
||||
feet: false,
|
||||
metric: usesMetricMeasurements(),
|
||||
feet: usesImperialMeasurements(),
|
||||
shapeOptions: {
|
||||
pane: "drawingsPane",
|
||||
renderer: lmeState.drawingsRenderer,
|
||||
@@ -3946,8 +4078,8 @@ function startMeasurementDrawing(mode) {
|
||||
}
|
||||
}),
|
||||
distance: () => new L.Draw.Polyline(lmeState.map, {
|
||||
metric: true,
|
||||
feet: false,
|
||||
metric: usesMetricMeasurements(),
|
||||
feet: usesImperialMeasurements(),
|
||||
showLength: true,
|
||||
shapeOptions: {
|
||||
pane: "drawingsPane",
|
||||
@@ -3958,8 +4090,8 @@ function startMeasurementDrawing(mode) {
|
||||
}
|
||||
}),
|
||||
circle: () => new L.Draw.Circle(lmeState.map, {
|
||||
metric: true,
|
||||
feet: false,
|
||||
metric: usesMetricMeasurements(),
|
||||
feet: usesImperialMeasurements(),
|
||||
showRadius: true,
|
||||
shapeOptions: {
|
||||
pane: "drawingsPane",
|
||||
@@ -4363,6 +4495,14 @@ function addMeasurementLabel(targetGroup, latLng, html, labelType, context) {
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (usesImperialMeasurements()) {
|
||||
const feet = meters * 3.280839895;
|
||||
if (feet >= 5280) {
|
||||
return `${formatNumber(feet / 5280, 3)} mi`;
|
||||
}
|
||||
return `${formatNumber(feet, feet < 100 ? 1 : 0)} ft`;
|
||||
}
|
||||
|
||||
if (meters >= 1000) {
|
||||
return `${formatNumber(meters / 1000, 3)} km`;
|
||||
}
|
||||
@@ -4370,6 +4510,15 @@ function formatDistance(meters) {
|
||||
}
|
||||
|
||||
function formatArea(squareMeters) {
|
||||
if (usesImperialMeasurements()) {
|
||||
const squareFeet = squareMeters * 10.763910417;
|
||||
const acres = squareMeters * 0.000247105381;
|
||||
if (acres >= 1) {
|
||||
return `${formatNumber(acres, 3)} ac`;
|
||||
}
|
||||
return `${formatNumber(squareFeet, squareFeet < 100 ? 1 : 0)} ft²`;
|
||||
}
|
||||
|
||||
if (squareMeters >= 10000) {
|
||||
return `${formatNumber(squareMeters / 10000, 4)} ha`;
|
||||
}
|
||||
@@ -4377,12 +4526,20 @@ function formatArea(squareMeters) {
|
||||
}
|
||||
|
||||
function formatNumber(value, digits) {
|
||||
return new Intl.NumberFormat("it-IT", {
|
||||
return new Intl.NumberFormat(lmeState.culture || "it-IT", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: digits
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function usesMetricMeasurements() {
|
||||
return lmeState.measurementSystem !== "imperial";
|
||||
}
|
||||
|
||||
function usesImperialMeasurements() {
|
||||
return lmeState.measurementSystem === "imperial";
|
||||
}
|
||||
|
||||
function createMapExportGeoJson(options) {
|
||||
migrateMisclassifiedImportedSheets();
|
||||
const requested = options || {};
|
||||
@@ -4551,9 +4708,6 @@ function loadProjectSnapshot(raw, projectName) {
|
||||
lmeState.importedSheetsVisible = projectDisplayOptions
|
||||
? Boolean(projectDisplayOptions.importedSheetsVisible)
|
||||
: data.schemaVersion >= 2 && Boolean(data.importedSheetsVisible);
|
||||
if (lmeState.importedSheetsVisible) {
|
||||
ensureImportedSheetOutline();
|
||||
}
|
||||
applyImportedSheetVisibility();
|
||||
L.geoJSON(data.selectedParcels || emptyFeatureCollection(), {
|
||||
pane: "selectedParcelsPane",
|
||||
@@ -4576,6 +4730,12 @@ function loadProjectSnapshot(raw, projectName) {
|
||||
applyStyleFromProperties(layer);
|
||||
}
|
||||
}).addTo(lmeState.selectedParcelsLayer);
|
||||
if (lmeState.importedSheetsVisible) {
|
||||
void ensureImportedSheetOutlines().then(() => {
|
||||
applyImportedSheetVisibility();
|
||||
refreshLayerManager();
|
||||
});
|
||||
}
|
||||
|
||||
lmeState.gpsTrailPoints = Array.isArray(data.trail) ? data.trail : [];
|
||||
lmeState.gpsTrailStartedAt = data.trailStartedAt || null;
|
||||
@@ -5559,7 +5719,16 @@ function parcelMemberToFeature(member) {
|
||||
}
|
||||
|
||||
const properties = {};
|
||||
["INSPIREID_LOCALID", "INSPIREID_NAMESPACE", "LABEL", "NATIONALCADASTRALREFERENCE", "ADMINISTRATIVEUNIT"].forEach(name => {
|
||||
[
|
||||
"INSPIREID_LOCALID",
|
||||
"INSPIREID_NAMESPACE",
|
||||
"LABEL",
|
||||
"NATIONALCADASTRALREFERENCE",
|
||||
"NATIONALCADASTRALZONINGREFERENCE",
|
||||
"LEVEL",
|
||||
"LEVELNAME",
|
||||
"ADMINISTRATIVEUNIT"
|
||||
].forEach(name => {
|
||||
const value = member.getElementsByTagNameNS("*", name)[0]?.textContent;
|
||||
if (value) {
|
||||
properties[name] = value;
|
||||
|
||||
Reference in new issue
Block a user