diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 2af5f36..fec9e98 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -235,6 +235,10 @@

Disegni e misure

+ @if (!Entitlements.HasProAccess) + { + @L("drawings.base.limit") + }
@@ -494,8 +498,8 @@

@L("settings.pro.note")

@L("settings.pro.offline")

- - + +
@@ -725,6 +729,8 @@ private string MeasurementPreference { get; set; } = "auto"; private string ManualLicenseCode { get; set; } = string.Empty; private string ManualLicenseMessage { get; set; } = string.Empty; + private string MonthlyPrice { get; set; } = string.Empty; + private string YearlyPrice { get; set; } = string.Empty; private bool BillingBusy { get; set; } private string Status { get; set; } = "online"; private string SelectedBaseLayer { get; set; } = "satellite"; @@ -784,6 +790,12 @@ 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 string MonthlyPlanLabel => string.IsNullOrWhiteSpace(MonthlyPrice) + ? L("settings.pro.monthly") + : $"{L("settings.pro.monthly")} - {MonthlyPrice}/mese"; + private string YearlyPlanLabel => string.IsNullOrWhiteSpace(YearlyPrice) + ? L("settings.pro.yearly") + : $"{L("settings.pro.yearly")} - {YearlyPrice}/anno"; private CancellationTokenSource? CatalogCancellation { get; set; } private CancellationTokenSource? GpsLookupCancellation { get; set; } private CancellationTokenSource? ProjectAutoSaveCancellation { get; set; } @@ -892,6 +904,7 @@ await ApplyEntitlementsToMapAsync(); await ApplyFlagPreferencesToMapAsync(); await ApplyAdvancedOptions(); + await RefreshBillingStateAsync(); await CenterOnInitialLocationAsync(); await MigrateLegacyProjectAsync(); var offlineCount = await JS.InvokeAsync("lmeMap.getOfflineTileStats"); @@ -2118,9 +2131,26 @@ private Task ApplyEntitlementsToMapAsync() => JS.InvokeVoidAsync("lmeMap.setEntitlements", new { - advancedGeometry = CanUse(AppFeatures.AdvancedGeometry) + advancedGeometry = CanUse(AppFeatures.AdvancedGeometry), + maxDrawingObjects = Entitlements.HasProAccess ? (int?)null : EntitlementService.BaseDrawingObjectLimit, + drawingLimitMessage = L("drawings.base.limit.reached") }).AsTask(); + private async Task RefreshBillingStateAsync() + { + var plans = await Entitlements.GetSubscriptionPlansAsync(); + MonthlyPrice = plans.FirstOrDefault(plan => plan.BasePlanId == EntitlementService.MonthlyBasePlanId)?.FormattedPrice + ?? string.Empty; + YearlyPrice = plans.FirstOrDefault(plan => plan.BasePlanId == EntitlementService.YearlyBasePlanId)?.FormattedPrice + ?? string.Empty; + + if (DeviceInfo.Current.Platform == DevicePlatform.Android) + { + await Entitlements.RestorePurchasesAsync(); + await ApplyEntitlementsToMapAsync(); + } + } + private async Task RestorePurchases() { BillingBusy = true; diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 2fcda19..4d69678 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ it.liviomerola.lmemappecatastali.pro - 1.0.3 - 103 + 1.0.4 + 104 $(ApplicationDisplayVersion) @@ -43,6 +43,22 @@ 6.5 + + + false + + + + + <_Parameter1>PlayBillingVerificationUrl + <_Parameter2>$(PlayBillingVerificationUrl) + + + <_Parameter1>PlayBillingVerificationRequired + <_Parameter2>$(PlayBillingVerificationRequired) + + + 36 aab diff --git a/MauiProgram.cs b/MauiProgram.cs index 27cdd45..2653f34 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -29,6 +29,7 @@ public static class MauiProgram builder.Services.AddSingleton(IncomingFileService.Shared); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if ANDROID builder.Services.AddSingleton(); #else diff --git a/PLAY_STORE_PRO_CHECKLIST.md b/PLAY_STORE_PRO_CHECKLIST.md index 7a1a760..fda66e2 100644 --- a/PLAY_STORE_PRO_CHECKLIST.md +++ b/PLAY_STORE_PRO_CHECKLIST.md @@ -4,7 +4,7 @@ - Nome: LME Mappe Catastali Pro. - Package name Android: `it.liviomerola.lmemappecatastali.pro`. -- Versione iniziale Play: `1.0.0` / version code `100`. +- Versione corrente Play: `1.0.4` / version code `104`. - Formato Release Android: Android App Bundle (`.aab`). - Target SDK Release: Android 16 / API 36. @@ -17,7 +17,7 @@ Base gratuita: - consultazione mappa catastale; - ricerca localita e ricerca catastale; - GPS live; -- disegni e misure base; +- fino a 15 disegni, misure, POI e punti GPS creati dall'utente; - apertura e consultazione dei dati gia presenti. Pro: @@ -28,6 +28,7 @@ Pro: - mappe offline; - strumenti geometrici avanzati; - traccia GPS. +- disegni, misure, POI e punti GPS senza limite applicativo. 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. @@ -43,7 +44,9 @@ Il codice contiene solo identificativi di prodotto e base plan. Non inserire chi 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. +Il client supporta la verifica server-side dei purchase token tramite un endpoint HTTPS configurato in fase di build. Il servizio di riferimento e in `tools/LmePlayVerifier` e usa la Google Play Developer API senza incorporare credenziali nell'app. + +Per il test chiuso l'endpoint puo restare disattivato. Prima della produzione occorre pubblicare il servizio, autorizzare il relativo service account nella Play Console e compilare l'app con `PlayBillingVerificationUrl` e `PlayBillingVerificationRequired=true`. ## Licenze manuali diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index a0f23ed..7f133a6 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,6 +1,6 @@ # Informativa sulla privacy di LME Mappe Catastali Pro -Data di efficacia: 29 settembre 2026 +Data di efficacia: 30 settembre 2026 ## Titolare e contatti @@ -52,7 +52,7 @@ La sincronizzazione WebDAV e facoltativa. Endpoint, nome utente e password sono ## Licenze e acquisti -Gli acquisti e gli abbonamenti Google Play sono gestiti da Google. L'app riceve da Google Play le informazioni tecniche necessarie a determinare lo stato del prodotto acquistato e ad abilitare le funzioni Pro. Le informazioni dell'account e del pagamento sono trattate da Google secondo la sua informativa. +Gli acquisti e gli abbonamenti Google Play sono gestiti da Google. L'app riceve da Google Play le informazioni tecniche necessarie a determinare lo stato del prodotto acquistato e ad abilitare le funzioni Pro. Quando il servizio di verifica LME e configurato, l'app gli trasmette il token tecnico dell'acquisto, il pacchetto dell'app e l'identificativo del prodotto per verificare presso Google Play lo stato e la scadenza dell'abbonamento. Il token non contiene i dati della carta di pagamento e non viene usato per profilazione o pubblicita. Le informazioni dell'account e del pagamento sono trattate da Google secondo la sua informativa. Le licenze manuali contengono nome, cognome, email, identificativo e scadenza in un codice cifrato e firmato. Il codice viene conservato nell'archivio sicuro del dispositivo. Il titolare registra localmente gli stessi dati, insieme a data di emissione, scadenza, note e stato amministrativo, per gestire la licenza e l'assistenza. diff --git a/Platforms/Android/AndroidPlayBillingService.cs b/Platforms/Android/AndroidPlayBillingService.cs index fe20670..1fe6875 100644 --- a/Platforms/Android/AndroidPlayBillingService.cs +++ b/Platforms/Android/AndroidPlayBillingService.cs @@ -24,6 +24,35 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe .Build(); } + public async Task> GetSubscriptionPlansAsync( + CancellationToken cancellationToken = default) + { + try + { + var details = await GetProductDetailsAsync(cancellationToken); + return details?.GetSubscriptionOfferDetails()? + .Where(item => string.IsNullOrEmpty(item.OfferId)) + .Select(item => + { + var phase = item.PricingPhases.PricingPhaseList.LastOrDefault(); + return phase is null + ? null + : new PlaySubscriptionPlan(item.BasePlanId, phase.FormattedPrice, phase.BillingPeriod); + }) + .Where(item => item is not null) + .Cast() + .ToArray() ?? []; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return []; + } + } + public async Task RestoreAsync(CancellationToken cancellationToken = default) { try @@ -45,7 +74,9 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe } var acknowledgement = await AcknowledgeIfNeededAsync(purchase); - return acknowledgement ?? PlayBillingResult.Active("Abbonamento LME Pro ripristinato da Google Play."); + return acknowledgement ?? PlayBillingResult.Active( + "Abbonamento LME Pro ripristinato da Google Play.", + purchase.PurchaseToken); } catch (OperationCanceledException) { @@ -61,21 +92,7 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe { 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 details = await GetProductDetailsAsync(cancellationToken); var offer = details?.GetSubscriptionOfferDetails()? .FirstOrDefault(item => item.BasePlanId == basePlanId && string.IsNullOrEmpty(item.OfferId)); if (details is null || offer is null) @@ -144,7 +161,28 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe } var acknowledgement = await AcknowledgeIfNeededAsync(purchase); - completion.TrySetResult(acknowledgement ?? PlayBillingResult.Active("Abbonamento LME Pro attivato.")); + completion.TrySetResult(acknowledgement ?? PlayBillingResult.Active( + "Abbonamento LME Pro attivato.", + purchase.PurchaseToken)); + } + + private async Task GetProductDetailsAsync(CancellationToken cancellationToken) + { + await EnsureConnectedAsync(cancellationToken); + var product = QueryProductDetailsParams.Product.NewBuilder() + .SetProductId(EntitlementService.ProductId) + .SetProductType(BillingClient.ProductType.Subs) + .Build(); + var query = QueryProductDetailsParams.NewBuilder() + .SetProductList([product]) + .Build(); + var result = await _client.QueryProductDetailsAsync(query); + if (result.Result.ResponseCode != BillingResponseCode.Ok) + { + throw new InvalidOperationException(ToMessage(result.Result)); + } + + return result.ProductDetailsList.FirstOrDefault(); } private async Task EnsureConnectedAsync(CancellationToken cancellationToken) diff --git a/README.md b/README.md index 32e5787..2c5d4be 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Informativa privacy: [PRIVACY_POLICY.md](PRIVACY_POLICY.md). - 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 piano Base consente fino a 15 disegni, misure, POI e punti GPS; LME Pro non applica questo limite. - 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`. diff --git a/Services/AppLocalizationService.cs b/Services/AppLocalizationService.cs index 648a51b..ca17b0c 100644 --- a/Services/AppLocalizationService.cs +++ b/Services/AppLocalizationService.cs @@ -44,6 +44,8 @@ public sealed class AppLocalizationService ["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.", + ["drawings.base.limit"] = "Piano Base: fino a 15 disegni, misure, POI e punti GPS. LME Pro non ha limiti.", + ["drawings.base.limit.reached"] = "Hai raggiunto il limite di 15 oggetti del piano Base. Elimina un disegno o passa a LME Pro per continuare senza limiti.", ["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", @@ -92,6 +94,8 @@ public sealed class AppLocalizationService ["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.", + ["drawings.base.limit"] = "Base plan: up to 15 drawings, measurements, POIs and GPS points. LME Pro is unlimited.", + ["drawings.base.limit.reached"] = "You reached the Base plan limit of 15 objects. Delete a drawing or upgrade to LME Pro to continue without limits.", ["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", diff --git a/Services/EntitlementService.cs b/Services/EntitlementService.cs index b576675..f924291 100644 --- a/Services/EntitlementService.cs +++ b/Services/EntitlementService.cs @@ -1,10 +1,14 @@ namespace LMEMappeCatastaliApp.Services; -public sealed class EntitlementService(ManualLicenseService manualLicenses, IPlayBillingService playBilling) +public sealed class EntitlementService( + ManualLicenseService manualLicenses, + IPlayBillingService playBilling, + PlayPurchaseVerificationService purchaseVerification) { public const string ProductId = "lme_pro"; public const string MonthlyBasePlanId = "monthly"; public const string YearlyBasePlanId = "yearly"; + public const int BaseDrawingObjectLimit = 15; private const string GoogleProActivePreference = "entitlement_google_pro_active"; private const string GoogleExpiresAtPreference = "entitlement_google_pro_expires_at"; @@ -53,6 +57,10 @@ public sealed class EntitlementService(ManualLicenseService manualLicenses, IPla public bool RequiresPro(string feature) => ProFeatures.Contains(feature); public bool CanUse(string feature) => !RequiresPro(feature) || HasProAccess; + public Task> GetSubscriptionPlansAsync( + CancellationToken cancellationToken = default) => + playBilling.GetSubscriptionPlansAsync(cancellationToken); + public async Task LoadAsync() { IsGoogleProActive = Preferences.Default.Get(GoogleProActivePreference, false); @@ -85,32 +93,60 @@ public sealed class EntitlementService(ManualLicenseService manualLicenses, IPla public async Task RestorePurchasesAsync() { var result = await playBilling.RestoreAsync(); - ApplyPlayBillingResult(result); - return result; + return await ApplyPlayBillingResultAsync(result); } public async Task PurchaseGoogleProAsync(string basePlanId) { var result = await playBilling.PurchaseAsync(basePlanId); - ApplyPlayBillingResult(result); - return result; + return await ApplyPlayBillingResultAsync(result); } - private void ApplyPlayBillingResult(PlayBillingResult result) + private async Task ApplyPlayBillingResultAsync(PlayBillingResult result) { if (!result.Completed) { - return; + return result; } if (result.HasActiveSubscription) { + if (!string.IsNullOrWhiteSpace(result.PurchaseToken)) + { + var verification = await purchaseVerification.VerifyAsync(result.PurchaseToken); + if (verification.Verified) + { + if (verification.Active) + { + ApplyVerifiedGooglePro(verification.ExpiresAt); + return result with + { + Message = string.IsNullOrWhiteSpace(verification.Message) + ? result.Message + : verification.Message + }; + } + + ApplyGoogleBase(); + return PlayBillingResult.Base(string.IsNullOrWhiteSpace(verification.Message) + ? "L'abbonamento Google Play non risulta attivo." + : verification.Message); + } + + if (purchaseVerification.IsRequired) + { + return PlayBillingResult.Failed(verification.Message); + } + } + ApplyVerifiedGooglePro(null); } else { ApplyGoogleBase(); } + + return result; } private void SaveGoogleState() diff --git a/Services/IPlayBillingService.cs b/Services/IPlayBillingService.cs index 798d167..df48722 100644 --- a/Services/IPlayBillingService.cs +++ b/Services/IPlayBillingService.cs @@ -2,13 +2,22 @@ namespace LMEMappeCatastaliApp.Services; public interface IPlayBillingService { + Task> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default); Task RestoreAsync(CancellationToken cancellationToken = default); Task PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default); } -public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscription, bool UserCancelled, string Message) +public sealed record PlaySubscriptionPlan(string BasePlanId, string FormattedPrice, string BillingPeriod); + +public sealed record PlayBillingResult( + bool Completed, + bool HasActiveSubscription, + bool UserCancelled, + string Message, + string? PurchaseToken = null) { - public static PlayBillingResult Active(string message) => new(true, true, false, message); + public static PlayBillingResult Active(string message, string? purchaseToken = null) => + new(true, true, false, message, purchaseToken); 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); @@ -16,6 +25,9 @@ public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscriptio public sealed class UnavailablePlayBillingService : IPlayBillingService { + public Task> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + public Task RestoreAsync(CancellationToken cancellationToken = default) => Task.FromResult(PlayBillingResult.Failed("Google Play Billing รจ disponibile solo nell'app Android installata da Google Play.")); diff --git a/Services/PlayPurchaseVerificationService.cs b/Services/PlayPurchaseVerificationService.cs new file mode 100644 index 0000000..30126be --- /dev/null +++ b/Services/PlayPurchaseVerificationService.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Json; +using System.Reflection; + +namespace LMEMappeCatastaliApp.Services; + +public sealed class PlayPurchaseVerificationService +{ + private const string EndpointMetadataKey = "PlayBillingVerificationUrl"; + private const string RequiredMetadataKey = "PlayBillingVerificationRequired"; + private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) }; + + public bool IsConfigured => VerificationEndpoint is not null; + public bool IsRequired => ReadMetadata(RequiredMetadataKey).Equals("true", StringComparison.OrdinalIgnoreCase); + + private static Uri? VerificationEndpoint + { + get + { + var value = ReadMetadata(EndpointMetadataKey); + return Uri.TryCreate(value, UriKind.Absolute, out var endpoint) + && endpoint.Scheme == Uri.UriSchemeHttps + ? endpoint + : null; + } + } + + public async Task VerifyAsync( + string purchaseToken, + CancellationToken cancellationToken = default) + { + var endpoint = VerificationEndpoint; + if (endpoint is null) + { + return PlayPurchaseVerificationResult.NotConfigured(); + } + + try + { + using var response = await _httpClient.PostAsJsonAsync(endpoint, new + { + packageName = AppInfo.Current.PackageName, + productId = EntitlementService.ProductId, + purchaseToken + }, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return PlayPurchaseVerificationResult.Failed( + $"Verifica acquisto non disponibile ({(int)response.StatusCode})."); + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken); + return result is null + ? PlayPurchaseVerificationResult.Failed("Risposta di verifica acquisto non valida.") + : new(true, true, result.Active, result.ExpiresAt, result.Message ?? string.Empty); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return PlayPurchaseVerificationResult.Failed("Il server di verifica acquisti non ha risposto in tempo."); + } + catch (Exception error) + { + return PlayPurchaseVerificationResult.Failed( + $"Verifica acquisto non disponibile: {error.GetBaseException().Message}"); + } + } + + private static string ReadMetadata(string key) => + typeof(PlayPurchaseVerificationService).Assembly + .GetCustomAttributes() + .FirstOrDefault(attribute => attribute.Key == key)?.Value?.Trim() ?? string.Empty; + + private sealed record VerificationResponse(bool Active, DateTimeOffset? ExpiresAt, string? Message); +} + +public sealed record PlayPurchaseVerificationResult( + bool Configured, + bool Verified, + bool Active, + DateTimeOffset? ExpiresAt, + string Message) +{ + public static PlayPurchaseVerificationResult NotConfigured() => + new(false, false, false, null, "Server di verifica acquisti non configurato."); + + public static PlayPurchaseVerificationResult Failed(string message) => + new(true, false, false, null, message); +} diff --git a/tools/LmePlayVerifier/LmePlayVerifier.csproj b/tools/LmePlayVerifier/LmePlayVerifier.csproj new file mode 100644 index 0000000..a0fde20 --- /dev/null +++ b/tools/LmePlayVerifier/LmePlayVerifier.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + + + + + diff --git a/tools/LmePlayVerifier/Program.cs b/tools/LmePlayVerifier/Program.cs new file mode 100644 index 0000000..f610b0b --- /dev/null +++ b/tools/LmePlayVerifier/Program.cs @@ -0,0 +1,60 @@ +using Google.Apis.AndroidPublisher.v3; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Services; + +const string PackageName = "it.liviomerola.lmemappecatastali.pro"; +const string ProductId = "lme_pro"; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(_ => +{ + var credential = GoogleCredential.GetApplicationDefault() + .CreateScoped(AndroidPublisherService.Scope.Androidpublisher); + return new AndroidPublisherService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "LME Play subscription verifier" + }); +}); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapPost("/v1/google-play/subscriptions/verify", async ( + VerifySubscriptionRequest request, + AndroidPublisherService publisher, + CancellationToken cancellationToken) => +{ + if (request.PackageName != PackageName || request.ProductId != ProductId + || string.IsNullOrWhiteSpace(request.PurchaseToken)) + { + return Results.BadRequest(new VerifySubscriptionResponse(false, null, "Richiesta non valida.")); + } + + try + { + var purchase = await publisher.Purchases.Subscriptionsv2 + .Get(PackageName, request.PurchaseToken) + .ExecuteAsync(cancellationToken); + var active = purchase.SubscriptionState is "SUBSCRIPTION_STATE_ACTIVE" + or "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"; + var expiresAt = purchase.LineItems? + .Select(item => item.ExpiryTimeDateTimeOffset) + .Where(value => value.HasValue) + .Max(); + return Results.Ok(new VerifySubscriptionResponse( + active, + expiresAt, + active ? "Abbonamento LME Pro verificato." : "L'abbonamento Google Play non risulta attivo.")); + } + catch (Google.GoogleApiException error) when ((int)error.HttpStatusCode is 400 or 404) + { + return Results.Ok(new VerifySubscriptionResponse(false, null, "Acquisto Google Play non riconosciuto.")); + } +}); + +app.Run(); + +internal sealed record VerifySubscriptionRequest(string PackageName, string ProductId, string PurchaseToken); +internal sealed record VerifySubscriptionResponse(bool Active, DateTimeOffset? ExpiresAt, string Message); diff --git a/tools/LmePlayVerifier/README.md b/tools/LmePlayVerifier/README.md new file mode 100644 index 0000000..2a46581 --- /dev/null +++ b/tools/LmePlayVerifier/README.md @@ -0,0 +1,18 @@ +# LME Play verifier + +Servizio HTTPS per verificare i token degli abbonamenti `lme_pro` con la Google Play Developer API. + +## Configurazione + +1. Creare un service account in Google Cloud e concedergli nella Play Console il permesso di visualizzare ordini e abbonamenti dell'app. +2. Conservare il JSON del service account solo sul server. +3. Impostare `GOOGLE_APPLICATION_CREDENTIALS` con il percorso assoluto del JSON. +4. Pubblicare il servizio dietro HTTPS. +5. Compilare l'app con: + +```powershell +-p:PlayBillingVerificationUrl=https://example.test/v1/google-play/subscriptions/verify +-p:PlayBillingVerificationRequired=true +``` + +Non inserire il JSON del service account, token di acquisto o credenziali nel repository o nell'AAB. diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index b6487a9..a649f71 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -96,7 +96,9 @@ const lmeState = { culture: "it-IT", measurementSystem: "metric", entitlements: { - advancedGeometry: false + advancedGeometry: false, + maxDrawingObjects: 15, + drawingLimitMessage: "Hai raggiunto il limite di 15 oggetti del piano Base." }, labels: { measureArea: "Misura area", @@ -213,6 +215,20 @@ function requireProFeature(feature) { return false; } +function drawingObjectCount() { + return lmeState.drawings ? managedRecordsForCategory("drawings").length : 0; +} + +function canAddDrawingObjects(amount = 1) { + const configuredMaximum = lmeState.entitlements?.maxDrawingObjects; + if (configuredMaximum === null || configuredMaximum === undefined || configuredMaximum === "") return true; + const maximum = Number(configuredMaximum); + if (!Number.isFinite(maximum) || maximum < 0) return true; + if (drawingObjectCount() + Math.max(0, Number(amount) || 0) <= maximum) return true; + setInfo(lmeState.entitlements?.drawingLimitMessage || `Limite di ${maximum} oggetti raggiunto.`); + return false; +} + function init(dotNetReference) { if (dotNetReference) { lmeState.dotNetReference = dotNetReference; @@ -1775,6 +1791,10 @@ function moveManagedRecordsToGroup(records, targetCategory, targetSource, target setInfo("Sblocca il gruppo prima di spostarlo."); return; } + const additionalDrawings = targetCategory === "drawings" + ? movable.filter(record => record.categoryKey !== "drawings").length + : 0; + if (!canAddDrawingObjects(additionalDrawings)) return; rememberMapChange(); const features = movable.map(record => { const feature = record.layer.toGeoJSON(); @@ -2684,6 +2704,7 @@ function storedFeatureStyle(feature) { function duplicateManagedRecords(records) { if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; } if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da duplicare."); return; } + if (!canAddDrawingObjects(records.length)) return; rememberMapChange(); records.forEach((record, index) => { const feature = record.layer.toGeoJSON(); @@ -3036,6 +3057,11 @@ function restoreLastTrashItem() { if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; } const entry = lmeState.trash.pop(); if (!entry) { setInfo("Il cestino e' vuoto."); return false; } + const drawingsToRestore = entry.items.filter(item => !["imported", "importedSheets"].includes(item.categoryKey)).length; + if (!canAddDrawingObjects(drawingsToRestore)) { + lmeState.trash.push(entry); + return false; + } rememberMapChange(); entry.items.forEach(item => { if (item.categoryKey === "imported") addImportedFeatures({ type: "FeatureCollection", features: [item.feature] }); @@ -3825,6 +3851,7 @@ function clearImported() { } function addProjectMarker(name) { + if (!canAddDrawingObjects()) return false; rememberMapChange(); const center = lmeState.map.getCenter(); const label = (name || "Nuovo POI").trim(); @@ -3840,6 +3867,7 @@ function addProjectMarker(name) { refreshLayerManager(); selectDrawingLayer(marker); finishMapChange(); + return true; } function configureDrawLabels() { @@ -3868,6 +3896,7 @@ function bindDrawingEvents() { setInfo("Il progetto e' in sola lettura."); return; } + if (!canAddDrawingObjects()) return; snapDrawingLayer(event.layer); annotateDrawingLayer(event.layer, event.layerType); lmeState.drawings.addLayer(event.layer); @@ -4104,6 +4133,7 @@ function startMeasurementDrawing(mode) { if (!lmeState.map) { return; } + if (!canAddDrawingObjects()) return; bindDrawingEvents(); hideDrawingScopeMenu(); if (lmeState.activeDrawHandler) { @@ -5736,6 +5766,7 @@ function bearingToCardinal(value) { function saveGpsMeasurementPoint() { if (!lmeState.lastPosition) { setInfo("Attendi una posizione GPS valida."); return; } + if (!canAddDrawingObjects()) return; rememberMapChange(); const now = new Date(); addDrawingFeature({ type: "Feature", properties: {