Add subscription pricing and Base drawing limit

This commit is contained in:
livio committed 2026-09-30 10:12:24 +02:00
1 parent ad64d6def0
commit 6a675ce635
15 files changed
+384 -37

No files matched your search

+33 -3
View File
@@ -235,6 +235,10 @@
<section class="panel-section">
<h2>Disegni e misure</h2>
@if (!Entitlements.HasProAccess)
{
<small class="field-help">@L("drawings.base.limit")</small>
}
<div class="button-row">
<button class="secondary-command" @onclick="UndoMapChange">Annulla</button>
<button class="secondary-command" @onclick="RedoMapChange">Ripristina</button>
@@ -494,8 +498,8 @@
<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>
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.MonthlyBasePlanId)' disabled="@BillingBusy">@MonthlyPlanLabel</button>
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.YearlyBasePlanId)' disabled="@BillingBusy">@YearlyPlanLabel</button>
</div>
<div class="button-row">
<button class="secondary-command" @onclick="RestorePurchases" disabled="@BillingBusy">@L("settings.pro.restore")</button>
@@ -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<int>("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;
+18 -2
View File
@@ -28,8 +28,8 @@
<ApplicationId>it.liviomerola.lmemappecatastali.pro</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0.3</ApplicationDisplayVersion>
<ApplicationVersion>103</ApplicationVersion>
<ApplicationDisplayVersion>1.0.4</ApplicationDisplayVersion>
<ApplicationVersion>104</ApplicationVersion>
<Version>$(ApplicationDisplayVersion)</Version>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
@@ -43,6 +43,22 @@
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<PropertyGroup>
<PlayBillingVerificationUrl Condition="'$(PlayBillingVerificationUrl)' == ''"></PlayBillingVerificationUrl>
<PlayBillingVerificationRequired Condition="'$(PlayBillingVerificationRequired)' == ''">false</PlayBillingVerificationRequired>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>PlayBillingVerificationUrl</_Parameter1>
<_Parameter2>$(PlayBillingVerificationUrl)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>PlayBillingVerificationRequired</_Parameter1>
<_Parameter2>$(PlayBillingVerificationRequired)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
<AndroidPackageFormat>aab</AndroidPackageFormat>
+1
View File
@@ -29,6 +29,7 @@ public static class MauiProgram
builder.Services.AddSingleton(IncomingFileService.Shared);
builder.Services.AddSingleton<AppLocalizationService>();
builder.Services.AddSingleton<ManualLicenseService>();
builder.Services.AddSingleton<PlayPurchaseVerificationService>();
#if ANDROID
builder.Services.AddSingleton<IPlayBillingService, Platforms.Android.AndroidPlayBillingService>();
#else
+6 -3
View File
@@ -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
+2 -2
View File
@@ -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.
+55 -17
View File
@@ -24,6 +24,35 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe
.Build();
}
public async Task<IReadOnlyList<PlaySubscriptionPlan>> 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<PlaySubscriptionPlan>()
.ToArray() ?? [];
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return [];
}
}
public async Task<PlayBillingResult> 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<ProductDetails?> 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)
+1
View File
@@ -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`.
+4
View File
@@ -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",
+43 -7
View File
@@ -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<IReadOnlyList<PlaySubscriptionPlan>> 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<PlayBillingResult> RestorePurchasesAsync()
{
var result = await playBilling.RestoreAsync();
ApplyPlayBillingResult(result);
return result;
return await ApplyPlayBillingResultAsync(result);
}
public async Task<PlayBillingResult> 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<PlayBillingResult> 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()
+14 -2
View File
@@ -2,13 +2,22 @@ namespace LMEMappeCatastaliApp.Services;
public interface IPlayBillingService
{
Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default);
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 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<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<PlaySubscriptionPlan>>([]);
public Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(PlayBillingResult.Failed("Google Play Billing è disponibile solo nell'app Android installata da Google Play."));
@@ -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<PlayPurchaseVerificationResult> 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<VerificationResponse>(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<AssemblyMetadataAttribute>()
.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);
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Apis.AndroidPublisher.v3" Version="1.76.0.4277" />
</ItemGroup>
</Project>
+60
View File
@@ -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);
+18
View File
@@ -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.
+32 -1
View File
@@ -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: {