Prepare Pro 1.0.1 imports and license tooling
This commit is contained in:
1 parent
62f28c5621
commit
0b5017739c
13 files changed
+418
-29
No files matched your search
+128
-3
@@ -10,6 +10,7 @@
|
||||
@inject MapExportService MapExportService
|
||||
@inject CloudSyncService CloudSyncService
|
||||
@inject AppUpdateService AppUpdateService
|
||||
@inject IncomingFileService IncomingFiles
|
||||
@inject AppLocalizationService L10n
|
||||
@inject EntitlementService Entitlements
|
||||
@implements IAsyncDisposable
|
||||
@@ -741,6 +742,7 @@
|
||||
private bool NavigationMenuOpen { get; set; }
|
||||
private bool HasUnsavedChanges { get; set; }
|
||||
private DotNetObjectReference<Home>? MapCallbackReference { get; set; }
|
||||
private bool MapReady { get; set; }
|
||||
private bool IncludeCadastralParcels { get; set; } = true;
|
||||
private bool IncludeKmlSheetLayer { get; set; }
|
||||
private bool ShowImportedSheetLayer { get; set; }
|
||||
@@ -830,6 +832,7 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IncomingFiles.FileAvailable += OnIncomingFileAvailable;
|
||||
L10n.Load();
|
||||
L10n.ApplyCulture();
|
||||
await Entitlements.LoadAsync();
|
||||
@@ -878,6 +881,7 @@
|
||||
{
|
||||
MapCallbackReference = DotNetObjectReference.Create(this);
|
||||
await JS.InvokeVoidAsync("lmeMap.init", MapCallbackReference);
|
||||
MapReady = true;
|
||||
await ApplyLocalizationToMapAsync();
|
||||
await ApplyEntitlementsToMapAsync();
|
||||
await ApplyFlagPreferencesToMapAsync();
|
||||
@@ -886,6 +890,7 @@
|
||||
await MigrateLegacyProjectAsync();
|
||||
var offlineCount = await JS.InvokeAsync<int>("lmeMap.getOfflineTileStats");
|
||||
OfflineStatus = offlineCount == 0 ? "Nessuna tessera salvata." : $"{offlineCount} tessere disponibili offline.";
|
||||
await ProcessIncomingFilesAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
@@ -1582,7 +1587,11 @@
|
||||
private async Task RestoreBackup()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli backup LME" });
|
||||
var file = await FilePicker.Default.PickAsync(new PickOptions
|
||||
{
|
||||
PickerTitle = "Scegli backup LME (.lmebackup.zip)",
|
||||
FileTypes = BackupPickerFileTypes
|
||||
});
|
||||
if (file is null) return;
|
||||
var path = await CopyPickedFileToCacheAsync(file);
|
||||
var count = await ProjectStorageService.RestoreBackupAsync(path);
|
||||
@@ -1593,7 +1602,11 @@
|
||||
private async Task ImportProject()
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; }
|
||||
var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli progetto LME" });
|
||||
var file = await FilePicker.Default.PickAsync(new PickOptions
|
||||
{
|
||||
PickerTitle = "Scegli progetto LME (.lmeproject.json)",
|
||||
FileTypes = ProjectPickerFileTypes
|
||||
});
|
||||
if (file is null) return;
|
||||
var path = await CopyPickedFileToCacheAsync(file);
|
||||
var imported = await ProjectStorageService.ImportProjectAsync(path);
|
||||
@@ -1612,6 +1625,117 @@
|
||||
return path;
|
||||
}
|
||||
|
||||
private void OnIncomingFileAvailable()
|
||||
{
|
||||
if (MapReady)
|
||||
{
|
||||
_ = InvokeAsync(ProcessIncomingFilesAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessIncomingFilesAsync()
|
||||
{
|
||||
while (IncomingFiles.TryDequeue(out var incoming) && incoming is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lowerName = incoming.FileName.ToLowerInvariant();
|
||||
if (lowerName.EndsWith(".kml", StringComparison.Ordinal)
|
||||
|| lowerName.EndsWith(".kmz", StringComparison.Ordinal)
|
||||
|| lowerName.EndsWith(".geojson", StringComparison.Ordinal)
|
||||
|| await IsGeoJsonAsync(incoming.Path))
|
||||
{
|
||||
var content = Convert.ToBase64String(await File.ReadAllBytesAsync(incoming.Path));
|
||||
await JS.InvokeAsync<string>("lmeMap.importExternalGisFile", incoming.FileName, content, incoming.ContentType);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lowerName.EndsWith(".zip", StringComparison.Ordinal))
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive))
|
||||
{
|
||||
await ShowProRequiredAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
var count = await ProjectStorageService.RestoreBackupAsync(incoming.Path);
|
||||
await RefreshProjectsAsync();
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Backup aperto: {count} progetti ripristinati.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lowerName.EndsWith(".json", StringComparison.Ordinal))
|
||||
{
|
||||
if (!CanUse(AppFeatures.ProjectArchive))
|
||||
{
|
||||
await ShowProRequiredAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
var imported = await ProjectStorageService.ImportProjectAsync(incoming.Path);
|
||||
await RefreshProjectsAsync();
|
||||
SelectedProjectId = imported.Id;
|
||||
await RefreshProjectVersionsAsync(true);
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto aperto: {imported.Name}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Formato non riconosciuto: {incoming.FileName}.");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Impossibile aprire {incoming.FileName}: {error.GetBaseException().Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(incoming.Path); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> IsGeoJsonAsync(string path)
|
||||
{
|
||||
if (!Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var document = await System.Text.Json.JsonDocument.ParseAsync(stream);
|
||||
if (!document.RootElement.TryGetProperty("type", out var typeProperty))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var type = typeProperty.GetString();
|
||||
return type is "Feature" or "FeatureCollection" or "Point" or "MultiPoint"
|
||||
or "LineString" or "MultiLineString" or "Polygon" or "MultiPolygon"
|
||||
or "GeometryCollection";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly FilePickerFileType ProjectPickerFileTypes = new(new Dictionary<DevicePlatform, IEnumerable<string>>
|
||||
{
|
||||
[DevicePlatform.Android] = ["application/json", "text/json", "text/plain", "application/octet-stream"],
|
||||
[DevicePlatform.WinUI] = [".json"],
|
||||
[DevicePlatform.iOS] = ["public.json", "public.data"],
|
||||
[DevicePlatform.macOS] = ["public.json", "public.data"]
|
||||
});
|
||||
|
||||
private static readonly FilePickerFileType BackupPickerFileTypes = new(new Dictionary<DevicePlatform, IEnumerable<string>>
|
||||
{
|
||||
[DevicePlatform.Android] = ["application/zip", "application/x-zip-compressed", "application/octet-stream"],
|
||||
[DevicePlatform.WinUI] = [".zip"],
|
||||
[DevicePlatform.iOS] = ["public.zip-archive", "public.data"],
|
||||
[DevicePlatform.macOS] = ["public.zip-archive", "public.data"]
|
||||
});
|
||||
|
||||
private async Task LoadProjectVersion()
|
||||
{
|
||||
if (SelectedProject is null || string.IsNullOrWhiteSpace(SelectedVersionId)) return;
|
||||
@@ -1928,7 +2052,7 @@
|
||||
UpdateStatus = $"Installata {AppUpdateService.CurrentVersion}. Disponibile {update.Version}.";
|
||||
UpdateDownloadUrl = update.DownloadUrl;
|
||||
}
|
||||
else UpdateStatus = $"Versione {AppUpdateService.CurrentVersion} aggiornata. Ultima release Gitea: {update.Version}.";
|
||||
else UpdateStatus = $"Versione {AppUpdateService.CurrentVersion} aggiornata. Ultima release Pro su Gitea: {update.Version}.";
|
||||
}
|
||||
catch (Exception error) { UpdateStatus = $"Controllo non riuscito: {error.GetBaseException().Message}"; }
|
||||
finally { UpdateBusy = false; }
|
||||
@@ -2349,6 +2473,7 @@
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IncomingFiles.FileAvailable -= OnIncomingFileAvailable;
|
||||
CancelCatalogWork();
|
||||
LocationService.StopTracking();
|
||||
GpsLookupCancellation?.Cancel();
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<ApplicationId>it.liviomerola.lmemappecatastali.pro</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>100</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.0.1</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>101</ApplicationVersion>
|
||||
<Version>$(ApplicationDisplayVersion)</Version>
|
||||
|
||||
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||
|
||||
@@ -26,6 +26,7 @@ public static class MauiProgram
|
||||
builder.Services.AddSingleton<MapExportService>();
|
||||
builder.Services.AddSingleton<CloudSyncService>();
|
||||
builder.Services.AddSingleton<AppUpdateService>();
|
||||
builder.Services.AddSingleton(IncomingFileService.Shared);
|
||||
builder.Services.AddSingleton<AppLocalizationService>();
|
||||
builder.Services.AddSingleton<ManualLicenseService>();
|
||||
#if ANDROID
|
||||
|
||||
@@ -1,10 +1,83 @@
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.Database;
|
||||
using Android.OS;
|
||||
using Android.Provider;
|
||||
using LMEMappeCatastaliApp.Services;
|
||||
|
||||
namespace LMEMappeCatastaliApp;
|
||||
|
||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/json")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/geo+json")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/geojson")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/vnd.google-earth.kml+xml")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/vnd.google-earth.kmz")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/xml")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "text/xml")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/zip")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/x-zip-compressed")]
|
||||
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/octet-stream")]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
QueueViewedFile(Intent);
|
||||
}
|
||||
|
||||
protected override void OnNewIntent(Intent? intent)
|
||||
{
|
||||
base.OnNewIntent(intent);
|
||||
Intent = intent;
|
||||
QueueViewedFile(intent);
|
||||
}
|
||||
|
||||
private void QueueViewedFile(Intent? intent)
|
||||
{
|
||||
if (intent?.Action == Intent.ActionView && intent.Data is not null)
|
||||
{
|
||||
_ = CopyViewedFileAsync(intent);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyViewedFileAsync(Intent intent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = intent.Data!;
|
||||
var fileName = ReadDisplayName(uri) ?? $"file-{Guid.NewGuid():N}";
|
||||
fileName = string.Concat(fileName.Select(character => Path.GetInvalidFileNameChars().Contains(character) ? '_' : character));
|
||||
var directory = Path.Combine(FileSystem.CacheDirectory, "Incoming");
|
||||
Directory.CreateDirectory(directory);
|
||||
var path = Path.Combine(directory, $"{Guid.NewGuid():N}-{fileName}");
|
||||
await using var input = ContentResolver!.OpenInputStream(uri)
|
||||
?? throw new IOException("Impossibile leggere il file selezionato.");
|
||||
await using var output = File.Create(path);
|
||||
await input.CopyToAsync(output);
|
||||
IncomingFileService.Shared.Enqueue(new IncomingFile(path, fileName, intent.Type));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Apertura file LME non riuscita: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
private string? ReadDisplayName(Android.Net.Uri uri)
|
||||
{
|
||||
if (uri.Scheme == ContentResolver.SchemeFile)
|
||||
{
|
||||
return Path.GetFileName(uri.Path);
|
||||
}
|
||||
|
||||
using ICursor? cursor = ContentResolver?.Query(uri, [IOpenableColumns.DisplayName], null, null, null);
|
||||
if (cursor is null || !cursor.MoveToFirst())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var index = cursor.GetColumnIndex(IOpenableColumns.DisplayName);
|
||||
return index >= 0 ? cursor.GetString(index) : null;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS.
|
||||
|
||||
## Versione Pro 1.0.1 test
|
||||
|
||||
- Il controllo aggiornamenti Pro ignora le release e gli APK della versione Base e segue esclusivamente il canale Pro.
|
||||
- KML e KMZ generici conservano punti, linee e poligoni; il filtro particelle viene applicato soltanto ai file riconosciuti come catastali.
|
||||
- Android propone LME tra le app disponibili per aprire KML, KMZ, GeoJSON, progetti JSON e backup ZIP.
|
||||
- I selettori file Android mostrano correttamente progetti condivisi e backup con estensione composta.
|
||||
- Il gestore licenze mantiene una chiave locale incompatibile come backup e consente il ripristino della chiave corretta senza arrestarsi.
|
||||
- I comandi di backup e ripristino chiave restano visibili anche su finestre strette.
|
||||
|
||||
## Versione 0.4.7
|
||||
|
||||
## Preparazione versione Pro Play Store
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class AppUpdateService
|
||||
{
|
||||
private const string LatestReleaseUrl = "https://gitea.lmcloud.it/api/v1/repos/livio/LMEMappeCatastaliApp/releases/latest";
|
||||
private const string ReleasesUrl = "https://gitea.lmcloud.it/api/v1/repos/livio/LMEMappeCatastaliProApp/releases?limit=50";
|
||||
private static readonly Regex ProTagPattern = new(
|
||||
@"^v?(?<version>\d+\.\d+\.\d+)-pro(?:[-.].*)?$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||
|
||||
public static string CurrentVersion =>
|
||||
@@ -17,21 +21,53 @@ public sealed class AppUpdateService
|
||||
|
||||
public async Task<AppUpdateResult> CheckAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var release = await _httpClient.GetFromJsonAsync<GiteaRelease>(LatestReleaseUrl, cancellationToken)
|
||||
var releases = await _httpClient.GetFromJsonAsync<GiteaRelease[]>(ReleasesUrl, cancellationToken)
|
||||
?? throw new InvalidDataException("Risposta aggiornamenti non valida.");
|
||||
var latestText = (release.TagName ?? string.Empty).TrimStart('v', 'V');
|
||||
_ = Version.TryParse(CurrentVersion, out var current);
|
||||
_ = Version.TryParse(latestText, out var latest);
|
||||
var apk = release.Assets?.FirstOrDefault(item => item.Name?.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) == true);
|
||||
return new(latest is not null && current is not null && latest > current, latestText, release.HtmlUrl ?? string.Empty, apk?.BrowserDownloadUrl ?? release.HtmlUrl ?? string.Empty, release.Body ?? string.Empty);
|
||||
GiteaRelease? selectedRelease = null;
|
||||
GiteaAsset? selectedApk = null;
|
||||
Version? selectedVersion = null;
|
||||
foreach (var release in releases.Where(item => !item.Draft))
|
||||
{
|
||||
var match = ProTagPattern.Match(release.TagName ?? string.Empty);
|
||||
var apk = release.Assets?.FirstOrDefault(item => IsProApk(item.Name));
|
||||
if (!match.Success || apk is null || !Version.TryParse(match.Groups["version"].Value, out var version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selectedVersion is null || version > selectedVersion)
|
||||
{
|
||||
selectedRelease = release;
|
||||
selectedApk = apk;
|
||||
selectedVersion = version;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedRelease is null || selectedApk is null || selectedVersion is null)
|
||||
{
|
||||
throw new InvalidDataException("Nessuna release compatibile con LME Mappe Catastali Pro trovata.");
|
||||
}
|
||||
|
||||
_ = Version.TryParse(CurrentVersion, out var current);
|
||||
return new(
|
||||
current is not null && selectedVersion > current,
|
||||
selectedVersion.ToString(3),
|
||||
selectedRelease.HtmlUrl ?? string.Empty,
|
||||
selectedApk.BrowserDownloadUrl ?? selectedRelease.HtmlUrl ?? string.Empty,
|
||||
selectedRelease.Body ?? string.Empty);
|
||||
}
|
||||
|
||||
private static bool IsProApk(string? name) =>
|
||||
name?.StartsWith("LME-Mappe-Catastali-Pro-", StringComparison.OrdinalIgnoreCase) == true
|
||||
&& name.EndsWith(".apk", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private sealed class GiteaRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
|
||||
[JsonPropertyName("html_url")] public string? HtmlUrl { get; set; }
|
||||
[JsonPropertyName("assets")] public GiteaAsset[]? Assets { get; set; }
|
||||
[JsonPropertyName("body")] public string? Body { get; set; }
|
||||
[JsonPropertyName("draft")] public bool Draft { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GiteaAsset
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class IncomingFileService
|
||||
{
|
||||
private readonly ConcurrentQueue<IncomingFile> _files = new();
|
||||
|
||||
public static IncomingFileService Shared { get; } = new();
|
||||
|
||||
public event Action? FileAvailable;
|
||||
|
||||
public void Enqueue(IncomingFile file)
|
||||
{
|
||||
_files.Enqueue(file);
|
||||
MainThread.BeginInvokeOnMainThread(() => FileAvailable?.Invoke());
|
||||
}
|
||||
|
||||
public bool TryDequeue(out IncomingFile? file) => _files.TryDequeue(out file);
|
||||
}
|
||||
|
||||
public sealed record IncomingFile(string Path, string FileName, string? ContentType);
|
||||
@@ -11,6 +11,12 @@ $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"
|
||||
$projectPath = Join-Path $projectRoot "LMEMappeCatastaliApp.csproj"
|
||||
[xml]$projectXml = Get-Content -Raw -LiteralPath $projectPath
|
||||
$appVersion = ($projectXml.Project.PropertyGroup | Where-Object ApplicationDisplayVersion | Select-Object -First 1).ApplicationDisplayVersion
|
||||
if ([string]::IsNullOrWhiteSpace($appVersion)) {
|
||||
throw "ApplicationDisplayVersion non trovata nel progetto."
|
||||
}
|
||||
|
||||
foreach ($requiredPath in @($dotnet, $keytool, $androidSdk)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
||||
@@ -50,7 +56,7 @@ $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") `
|
||||
& $dotnet publish $projectPath `
|
||||
-f net10.0-android `
|
||||
-c $Configuration `
|
||||
-p:AndroidSdkDirectory=$androidSdk `
|
||||
@@ -72,7 +78,7 @@ try {
|
||||
throw "Nessun pacchetto $format firmato prodotto in $publishDirectory"
|
||||
}
|
||||
|
||||
$destination = Join-Path $outputDirectory "LME-Mappe-Catastali-Pro-1.0.0-test.$format"
|
||||
$destination = Join-Path $outputDirectory "LME-Mappe-Catastali-Pro-$appVersion-test.$format"
|
||||
Copy-Item -LiteralPath $package.FullName -Destination $destination -Force
|
||||
$copiedPackages.Add((Get-Item -LiteralPath $destination))
|
||||
}
|
||||
@@ -89,6 +95,12 @@ $filesToHash = @($copiedPackages)
|
||||
if (Test-Path -LiteralPath $licenseManagerArchive) {
|
||||
$filesToHash += Get-Item -LiteralPath $licenseManagerArchive
|
||||
}
|
||||
$publicKeySource = Join-Path $projectRoot "tools\LmeLicenseManager\manual-license-public.pem"
|
||||
if (Test-Path -LiteralPath $publicKeySource) {
|
||||
$publicKeyDestination = Join-Path $outputDirectory "manual-license-public.pem"
|
||||
Copy-Item -LiteralPath $publicKeySource -Destination $publicKeyDestination -Force
|
||||
$filesToHash += Get-Item -LiteralPath $publicKeyDestination
|
||||
}
|
||||
|
||||
$hashLines = $filesToHash | ForEach-Object {
|
||||
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
|
||||
|
||||
@@ -239,16 +239,27 @@ internal sealed class LicenseRepository
|
||||
private void EnsureSigningKey()
|
||||
{
|
||||
if (File.Exists(PrivateKeyPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var existing = LoadPrivateKey();
|
||||
if (!_allowKeyGeneration && !IsExpectedSigningKey(existing))
|
||||
{
|
||||
throw new CryptographicException("La chiave locale non corrisponde alla chiave pubblica incorporata in LME Pro.");
|
||||
PreserveIncompatiblePrivateKey();
|
||||
File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII);
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(PublicKeyPath, existing.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII);
|
||||
return;
|
||||
}
|
||||
catch (CryptographicException) when (!_allowKeyGeneration)
|
||||
{
|
||||
PreserveIncompatiblePrivateKey();
|
||||
File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_allowKeyGeneration)
|
||||
{
|
||||
@@ -265,6 +276,15 @@ internal sealed class LicenseRepository
|
||||
File.SetAttributes(PrivateKeyPath, FileAttributes.Hidden);
|
||||
}
|
||||
|
||||
private void PreserveIncompatiblePrivateKey()
|
||||
{
|
||||
File.SetAttributes(PrivateKeyPath, FileAttributes.Normal);
|
||||
var preservedPath = Path.Combine(
|
||||
_directory,
|
||||
$"manual-license-private-incompatible-{DateTime.UtcNow:yyyyMMddHHmmss}.dpapi");
|
||||
File.Move(PrivateKeyPath, preservedPath);
|
||||
}
|
||||
|
||||
private static bool IsExpectedSigningKey(RSA rsa)
|
||||
{
|
||||
using var expected = RSA.Create();
|
||||
|
||||
@@ -65,16 +65,23 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
panel.Controls.Add(text, 0, 0);
|
||||
|
||||
var commands = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, Margin = new Padding(8, 0, 0, 12) };
|
||||
var commands = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = 3,
|
||||
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);
|
||||
commands.Controls.Add(openFolder, 0, 0);
|
||||
commands.Controls.Add(exportKey, 0, 1);
|
||||
commands.Controls.Add(importKey, 0, 2);
|
||||
panel.Controls.Add(commands, 1, 0);
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,21 @@ internal static class Program
|
||||
[STAThread]
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
if (args.Length == 2 && args[0].Equals("--export-key", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var password = Environment.GetEnvironmentVariable("LME_LICENSE_BACKUP_PASSWORD");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
throw new InvalidOperationException("Imposta LME_LICENSE_BACKUP_PASSWORD per esportare la chiave.");
|
||||
}
|
||||
|
||||
var exportRepository = new LicenseRepository();
|
||||
exportRepository.Initialize();
|
||||
exportRepository.ExportSigningKeyBackup(Path.GetFullPath(args[1]), password);
|
||||
Console.WriteLine($"Backup chiave creato: {Path.GetFullPath(args[1])}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Contains("--self-test", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
RunSelfTest();
|
||||
@@ -26,6 +41,7 @@ internal static class Program
|
||||
{
|
||||
var testDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}");
|
||||
var importedDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}");
|
||||
var incompatibleDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}");
|
||||
const string backupPassword = "LME-Test-Backup-Password-2026";
|
||||
try
|
||||
{
|
||||
@@ -60,11 +76,21 @@ internal static class Program
|
||||
throw new InvalidOperationException("La chiave importata non produce licenze compatibili.");
|
||||
}
|
||||
|
||||
var incompatibleRepository = new LicenseRepository(incompatibleDirectory, allowKeyGeneration: true);
|
||||
incompatibleRepository.Initialize();
|
||||
var strictRepository = new LicenseRepository(incompatibleDirectory);
|
||||
strictRepository.Initialize();
|
||||
if (File.Exists(Path.Combine(incompatibleDirectory, "manual-license-private.dpapi"))
|
||||
|| Directory.GetFiles(incompatibleDirectory, "manual-license-private-incompatible-*.dpapi").Length != 1)
|
||||
{
|
||||
throw new InvalidOperationException("La chiave incompatibile non e stata conservata correttamente.");
|
||||
}
|
||||
|
||||
Console.WriteLine("Self-test licenze e portabilita chiave completato.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var directory in new[] { testDirectory, importedDirectory })
|
||||
foreach (var directory in new[] { testDirectory, importedDirectory, incompatibleDirectory })
|
||||
{
|
||||
var fullPath = Path.GetFullPath(directory);
|
||||
var tempRoot = Path.GetFullPath(Path.GetTempPath());
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAmjh4E1fpaaonATNDipsR
|
||||
1iy5cNSStD7fgB7i5xJTSZV36Td1B7hQXfZB8Z7vx9iFJpbCQMePuFiDKNbTUQFf
|
||||
EKEp8lBq7SHo0LvbKNOSK9RMkqpb3ova9Er14gSCgshivf1c774VXTlt9J7uAWoE
|
||||
QeQMNKJUSogoMkxEKc4Xmye65lGjJVeiuqdDCJh5o42j8DfGwqOyGX4808vSeixe
|
||||
3lArRmoq9SGNO05VVunF8NKJyhiRLfropUvC+eFsiJMp3VMEYVn5FUcd8ljO67jv
|
||||
pnngWh6zCZxs+h0mT0YRU9F3tWHqqsG1PE6NZnspmBdNfUfb9LxKt1RshEhUWiBx
|
||||
0vvdUtg20nESOvFnieIX64pcx0LqFlDMmEWDMQjZ7KEtwXVphN1mhsryUiS6Cz3b
|
||||
oKTryH4FQY9iIWNNzdzXujrV5ycE49orFEtrXRPnXBlB97Hl3Dlxi19lvPwxWXQ5
|
||||
fKXrTJ2rL/WvlMpZ5YOggAmv7P7tA4B+Oi5uMiHzXEDlAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
+55
-8
@@ -182,6 +182,7 @@ window.lmeMap = {
|
||||
resize,
|
||||
setLocalization,
|
||||
setEntitlements,
|
||||
importExternalGisFile,
|
||||
showInfo: setInfo
|
||||
};
|
||||
|
||||
@@ -779,6 +780,32 @@ function bindFileImport() {
|
||||
if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
|
||||
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 20));
|
||||
await importGisFile(file);
|
||||
} catch (error) {
|
||||
setInfo(`File non leggibile: ${error.message}`);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
});
|
||||
lmeState.fileImportBound = true;
|
||||
}
|
||||
|
||||
async function importExternalGisFile(fileName, base64Content, mimeType) {
|
||||
const binary = atob(base64Content);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
const file = new File([bytes], fileName, { type: mimeType || "application/octet-stream" });
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
|
||||
}
|
||||
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 20));
|
||||
return importGisFile(file);
|
||||
}
|
||||
|
||||
async function importGisFile(file) {
|
||||
const result = await readGisFile(file);
|
||||
rememberMapChange();
|
||||
const geojson = result.geojson;
|
||||
@@ -798,17 +825,12 @@ function bindFileImport() {
|
||||
: "";
|
||||
const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : "";
|
||||
const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : "";
|
||||
setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`);
|
||||
const message = `Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`;
|
||||
setInfo(message);
|
||||
updateGpsInsideState();
|
||||
refreshLayerManager();
|
||||
finishMapChange();
|
||||
} catch (error) {
|
||||
setInfo(`File non leggibile: ${error.message}`);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
});
|
||||
lmeState.fileImportBound = true;
|
||||
return message;
|
||||
}
|
||||
|
||||
async function readGisFile(file) {
|
||||
@@ -1027,6 +1049,15 @@ function normalizeKmlFiles(files) {
|
||||
throw new Error("KML senza geometrie utilizzabili");
|
||||
}
|
||||
|
||||
if (!looksLikeCadastralKml(features)) {
|
||||
return {
|
||||
geojson: { type: "FeatureCollection", features },
|
||||
sheetGeojson: emptyFeatureCollection(),
|
||||
ignoredCount: 0,
|
||||
reprojected: false
|
||||
};
|
||||
}
|
||||
|
||||
const polygonParts = splitKmlPolygonFeatures(features);
|
||||
const parcels = filterKmlParcelFeatures(polygonParts);
|
||||
const parcelSet = new Set(parcels);
|
||||
@@ -1047,6 +1078,22 @@ function normalizeKmlFiles(files) {
|
||||
};
|
||||
}
|
||||
|
||||
function looksLikeCadastralKml(features) {
|
||||
return features.some(feature => {
|
||||
const properties = feature?.properties || {};
|
||||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||||
const context = [
|
||||
name,
|
||||
properties.layer,
|
||||
properties.Layer,
|
||||
properties.type,
|
||||
properties.Tipo,
|
||||
properties._lmeFolder
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return /\b(particell|mappal|catast|foglio|parcel|cadastral)\w*/i.test(context);
|
||||
});
|
||||
}
|
||||
|
||||
function kmlTextToGeoJson(text, sourceName) {
|
||||
const xml = new DOMParser().parseFromString(text, "text/xml");
|
||||
if (xml.getElementsByTagName("parsererror").length) {
|
||||
|
||||
Reference in new issue
Block a user