feat: prepare Pro licensing and Android API 36

This commit is contained in:
livio committed 2026-09-28 09:50:58 +02:00
1 parent b600412285
commit 11a91863a3
25 files changed
+2447 -62

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
using System.Globalization;
namespace LMEMappeCatastaliApp.Services;
public sealed class AppLocalizationService
{
public const string LanguagePreferenceKey = "app_language";
public const string MeasurementPreferenceKey = "measurement_system";
private static readonly HashSet<string> SupportedLanguages = new(StringComparer.OrdinalIgnoreCase)
{
"auto",
"it",
"en"
};
private static readonly HashSet<string> SupportedMeasurementSystems = new(StringComparer.OrdinalIgnoreCase)
{
"auto",
"metric",
"imperial"
};
private static readonly Dictionary<string, Dictionary<string, string>> Strings = new(StringComparer.OrdinalIgnoreCase)
{
["it"] = new(StringComparer.Ordinal)
{
["settings.localization.title"] = "Lingua e misure",
["settings.language"] = "Lingua",
["settings.language.auto"] = "Automatica",
["settings.language.it"] = "Italiano",
["settings.language.en"] = "English",
["settings.measurements"] = "Sistema di misura",
["settings.measurements.auto"] = "Automatico in base all'area",
["settings.measurements.metric"] = "Metrico",
["settings.measurements.imperial"] = "Imperiale",
["settings.pro.title"] = "Abbonamento Pro",
["settings.pro.status"] = "Stato",
["settings.pro.active"] = "Pro attivo",
["settings.pro.base"] = "Base gratuita",
["settings.pro.offline"] = "Offline: le funzioni Pro restano disponibili durante il periodo di tolleranza senza cancellare i dati.",
["settings.pro.restore"] = "Ripristina acquisti",
["settings.pro.manage"] = "Gestisci abbonamento",
["settings.pro.monthly"] = "Pro mensile",
["settings.pro.yearly"] = "Pro annuale",
["settings.pro.note"] = "Il prodotto LME Pro si configura nella Play Console con piano mensile e annuale.",
["settings.pro.manual.code"] = "Codice licenza manuale",
["settings.pro.manual.help"] = "Incolla il codice cifrato ricevuto. Nome, email e scadenza vengono letti solo dopo la verifica crittografica.",
["settings.pro.manual.activate"] = "Attiva codice",
["settings.pro.manual.remove"] = "Rimuovi licenza",
["settings.pro.manual.expires"] = "Scadenza",
["settings.pro.manual.valid"] = "Licenza manuale valida",
["settings.pro.manual.expired"] = "Licenza manuale scaduta: funzionalita Base attive",
["settings.pro.manual.activated"] = "Licenza manuale attivata.",
["settings.pro.manual.invalid"] = "Codice licenza non valido.",
["settings.pro.manual.removed"] = "Licenza manuale rimossa.",
["menu.quickHelp"] = "Guida rapida",
["menu.map"] = "Mappa",
["menu.search"] = "Cerca localita",
["menu.cadastre"] = "Ricerca catastale",
["menu.data"] = "Dati e disegni",
["menu.projects"] = "Progetti",
["menu.export"] = "Esporta",
["menu.tools"] = "Strumenti avanzati",
["menu.settings"] = "Impostazioni",
["panel.settings.title"] = "Impostazioni",
["panel.settings.eyebrow"] = "Mappa, GPS, Pro e servizi ufficiali",
["measure.area"] = "Misura area",
["measure.distance"] = "Misura distanza",
["measure.circle"] = "Misura cerchio",
["measure.rectangle"] = "Disegna rettangolo",
["measure.poi"] = "Aggiungi POI"
},
["en"] = new(StringComparer.Ordinal)
{
["settings.localization.title"] = "Language and units",
["settings.language"] = "Language",
["settings.language.auto"] = "Automatic",
["settings.language.it"] = "Italiano",
["settings.language.en"] = "English",
["settings.measurements"] = "Measurement system",
["settings.measurements.auto"] = "Automatic by region",
["settings.measurements.metric"] = "Metric",
["settings.measurements.imperial"] = "Imperial",
["settings.pro.title"] = "Pro subscription",
["settings.pro.status"] = "Status",
["settings.pro.active"] = "Pro active",
["settings.pro.base"] = "Free base plan",
["settings.pro.offline"] = "Offline: Pro features remain available during the grace period without deleting user data.",
["settings.pro.restore"] = "Restore purchases",
["settings.pro.manage"] = "Manage subscription",
["settings.pro.monthly"] = "Monthly Pro",
["settings.pro.yearly"] = "Yearly Pro",
["settings.pro.note"] = "Create the LME Pro product in Play Console with monthly and yearly base plans.",
["settings.pro.manual.code"] = "Manual license code",
["settings.pro.manual.help"] = "Paste the encrypted code you received. Name, email and expiry are read only after cryptographic verification.",
["settings.pro.manual.activate"] = "Activate code",
["settings.pro.manual.remove"] = "Remove license",
["settings.pro.manual.expires"] = "Expires",
["settings.pro.manual.valid"] = "Manual license valid",
["settings.pro.manual.expired"] = "Manual license expired: Base features active",
["settings.pro.manual.activated"] = "Manual license activated.",
["settings.pro.manual.invalid"] = "Invalid license code.",
["settings.pro.manual.removed"] = "Manual license removed.",
["menu.quickHelp"] = "Quick help",
["menu.map"] = "Map",
["menu.search"] = "Search place",
["menu.cadastre"] = "Cadastral search",
["menu.data"] = "Data and drawings",
["menu.projects"] = "Projects",
["menu.export"] = "Export",
["menu.tools"] = "Advanced tools",
["menu.settings"] = "Settings",
["panel.settings.title"] = "Settings",
["panel.settings.eyebrow"] = "Map, GPS, Pro and official services",
["measure.area"] = "Measure area",
["measure.distance"] = "Measure distance",
["measure.circle"] = "Measure circle",
["measure.rectangle"] = "Draw rectangle",
["measure.poi"] = "Add POI"
}
};
public string LanguagePreference { get; private set; } = "auto";
public string MeasurementPreference { get; private set; } = "auto";
public CultureInfo Culture => new(ResolveLanguage());
public string MeasurementSystem => MeasurementPreference == "auto"
? ResolveAutomaticMeasurementSystem()
: MeasurementPreference;
public void Load()
{
LanguagePreference = NormalizeLanguage(Preferences.Default.Get(LanguagePreferenceKey, "auto"));
MeasurementPreference = NormalizeMeasurementSystem(Preferences.Default.Get(MeasurementPreferenceKey, "auto"));
}
public void SetLanguage(string? language)
{
LanguagePreference = NormalizeLanguage(language);
Preferences.Default.Set(LanguagePreferenceKey, LanguagePreference);
ApplyCulture();
}
public void SetMeasurementSystem(string? measurementSystem)
{
MeasurementPreference = NormalizeMeasurementSystem(measurementSystem);
Preferences.Default.Set(MeasurementPreferenceKey, MeasurementPreference);
}
public void ApplyCulture()
{
var culture = Culture;
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
}
public string T(string key)
{
var language = ResolveLanguage();
if (Strings.TryGetValue(language, out var table) && table.TryGetValue(key, out var value))
{
return value;
}
return Strings["it"].TryGetValue(key, out var fallback) ? fallback : key;
}
private string ResolveLanguage()
{
if (LanguagePreference != "auto")
{
return LanguagePreference;
}
var twoLetter = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
return Strings.ContainsKey(twoLetter) ? twoLetter : "en";
}
private static string ResolveAutomaticMeasurementSystem()
{
var region = RegionInfo.CurrentRegion.TwoLetterISORegionName;
return region is "US" or "LR" or "MM" ? "imperial" : "metric";
}
private static string NormalizeLanguage(string? language) =>
!string.IsNullOrWhiteSpace(language) && SupportedLanguages.Contains(language) ? language.ToLowerInvariant() : "auto";
private static string NormalizeMeasurementSystem(string? measurementSystem) =>
!string.IsNullOrWhiteSpace(measurementSystem) && SupportedMeasurementSystems.Contains(measurementSystem) ? measurementSystem.ToLowerInvariant() : "auto";
}
+152
View File
@@ -0,0 +1,152 @@
namespace LMEMappeCatastaliApp.Services;
public sealed class EntitlementService(ManualLicenseService manualLicenses, IPlayBillingService playBilling)
{
public const string ProductId = "lme_pro";
public const string MonthlyBasePlanId = "monthly";
public const string YearlyBasePlanId = "yearly";
private const string GoogleProActivePreference = "entitlement_google_pro_active";
private const string GoogleExpiresAtPreference = "entitlement_google_pro_expires_at";
private const string GoogleLastVerifiedAtPreference = "entitlement_google_pro_last_verified_at";
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(7);
private static readonly HashSet<string> ProFeatures = new(StringComparer.Ordinal)
{
AppFeatures.ProjectArchive,
AppFeatures.Export,
AppFeatures.CloudSync,
AppFeatures.OfflineMaps,
AppFeatures.AdvancedGeometry,
AppFeatures.GpsTrail
};
public bool IsGoogleProActive { get; private set; }
public DateTimeOffset? GoogleExpiresAt { get; private set; }
public DateTimeOffset? GoogleLastVerifiedAt { get; private set; }
public ManualLicenseInfo? ManualLicense => manualLicenses.Current;
public string? ManualLicenseError => manualLicenses.LastError;
public bool HasGoogleProAccess
{
get
{
var now = DateTimeOffset.UtcNow;
if (!IsGoogleProActive || GoogleExpiresAt is { } expiresAt && expiresAt <= now)
{
return false;
}
return GoogleLastVerifiedAt is null
|| now - GoogleLastVerifiedAt <= OfflineGracePeriod;
}
}
public bool HasManualProAccess => manualLicenses.HasActiveLicense;
public bool HasProAccess => HasGoogleProAccess || HasManualProAccess;
public string PlanLabel => HasProAccess
? HasGoogleProAccess && HasManualProAccess ? "LME Pro (Google + manuale)"
: HasGoogleProAccess ? "LME Pro (Google Play)"
: "LME Pro (licenza manuale)"
: "Base";
public bool RequiresPro(string feature) => ProFeatures.Contains(feature);
public bool CanUse(string feature) => !RequiresPro(feature) || HasProAccess;
public async Task LoadAsync()
{
IsGoogleProActive = Preferences.Default.Get(GoogleProActivePreference, false);
GoogleExpiresAt = ReadDate(GoogleExpiresAtPreference);
GoogleLastVerifiedAt = ReadDate(GoogleLastVerifiedAtPreference);
await manualLicenses.LoadAsync();
}
public void ApplyVerifiedGooglePro(DateTimeOffset? expiresAt)
{
IsGoogleProActive = true;
GoogleExpiresAt = expiresAt;
GoogleLastVerifiedAt = DateTimeOffset.UtcNow;
SaveGoogleState();
}
public void ApplyGoogleBase()
{
IsGoogleProActive = false;
GoogleExpiresAt = null;
GoogleLastVerifiedAt = DateTimeOffset.UtcNow;
SaveGoogleState();
}
public Task<ManualLicenseActivationResult> ActivateManualLicenseAsync(string? token) =>
manualLicenses.ActivateAsync(token);
public Task RemoveManualLicenseAsync() => manualLicenses.RemoveAsync();
public async Task<PlayBillingResult> RestorePurchasesAsync()
{
var result = await playBilling.RestoreAsync();
ApplyPlayBillingResult(result);
return result;
}
public async Task<PlayBillingResult> PurchaseGoogleProAsync(string basePlanId)
{
var result = await playBilling.PurchaseAsync(basePlanId);
ApplyPlayBillingResult(result);
return result;
}
private void ApplyPlayBillingResult(PlayBillingResult result)
{
if (!result.Completed)
{
return;
}
if (result.HasActiveSubscription)
{
ApplyVerifiedGooglePro(null);
}
else
{
ApplyGoogleBase();
}
}
private void SaveGoogleState()
{
Preferences.Default.Set(GoogleProActivePreference, IsGoogleProActive);
WriteDate(GoogleExpiresAtPreference, GoogleExpiresAt);
WriteDate(GoogleLastVerifiedAtPreference, GoogleLastVerifiedAt);
}
private static DateTimeOffset? ReadDate(string key)
{
var value = Preferences.Default.Get(key, string.Empty);
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
private static void WriteDate(string key, DateTimeOffset? value)
{
if (value is null)
{
Preferences.Default.Remove(key);
return;
}
Preferences.Default.Set(key, value.Value.ToString("O"));
}
}
public static class AppFeatures
{
public const string CadastreMap = "cadastre-map";
public const string BasicSearch = "basic-search";
public const string DrawingMeasurements = "drawing-measurements";
public const string ProjectArchive = "project-archive";
public const string Export = "export";
public const string CloudSync = "cloud-sync";
public const string OfflineMaps = "offline-maps";
public const string AdvancedGeometry = "advanced-geometry";
public const string GpsTrail = "gps-trail";
}
+24
View File
@@ -0,0 +1,24 @@
namespace LMEMappeCatastaliApp.Services;
public interface IPlayBillingService
{
Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default);
Task<PlayBillingResult> PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default);
}
public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscription, bool UserCancelled, string Message)
{
public static PlayBillingResult Active(string message) => new(true, true, false, message);
public static PlayBillingResult Base(string message) => new(true, false, false, message);
public static PlayBillingResult Cancelled(string message) => new(false, false, true, message);
public static PlayBillingResult Failed(string message) => new(false, false, false, message);
}
public sealed class UnavailablePlayBillingService : IPlayBillingService
{
public Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(PlayBillingResult.Failed("Google Play Billing è disponibile solo nell'app Android installata da Google Play."));
public Task<PlayBillingResult> PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default) =>
Task.FromResult(PlayBillingResult.Failed("Google Play Billing è disponibile solo nell'app Android installata da Google Play."));
}
+226
View File
@@ -0,0 +1,226 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LMEMappeCatastaliApp.Services;
public sealed class ManualLicenseService
{
private const string SecureStorageKey = "lme_manual_license_token";
private const string LastObservedUtcPreference = "lme_license_last_observed_utc";
private const string ProductName = "lme-pro-manual";
private const string EncryptionKeyBase64 = "GiTTRSc1y4DseejPaA0NiXgy95DlgEsIe3JKdJ70sVQ=";
private const string PublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAmjh4E1fpaaonATNDipsR
1iy5cNSStD7fgB7i5xJTSZV36Td1B7hQXfZB8Z7vx9iFJpbCQMePuFiDKNbTUQFf
EKEp8lBq7SHo0LvbKNOSK9RMkqpb3ova9Er14gSCgshivf1c774VXTlt9J7uAWoE
QeQMNKJUSogoMkxEKc4Xmye65lGjJVeiuqdDCJh5o42j8DfGwqOyGX4808vSeixe
3lArRmoq9SGNO05VVunF8NKJyhiRLfropUvC+eFsiJMp3VMEYVn5FUcd8ljO67jv
pnngWh6zCZxs+h0mT0YRU9F3tWHqqsG1PE6NZnspmBdNfUfb9LxKt1RshEhUWiBx
0vvdUtg20nESOvFnieIX64pcx0LqFlDMmEWDMQjZ7KEtwXVphN1mhsryUiS6Cz3b
oKTryH4FQY9iIWNNzdzXujrV5ycE49orFEtrXRPnXBlB97Hl3Dlxi19lvPwxWXQ5
fKXrTJ2rL/WvlMpZ5YOggAmv7P7tA4B+Oi5uMiHzXEDlAgMBAAE=
-----END PUBLIC KEY-----
""";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private string? _token;
public ManualLicenseInfo? Current { get; private set; }
public string? LastError { get; private set; }
public bool HasActiveLicense => Current is { } license && license.ExpiresAtUtc > GetEffectiveUtcNow();
public async Task LoadAsync()
{
try
{
_token = await SecureStorage.Default.GetAsync(SecureStorageKey);
}
catch
{
_token = null;
}
ValidateStoredToken();
}
public async Task<ManualLicenseActivationResult> ActivateAsync(string? token)
{
var normalized = token?.Trim() ?? string.Empty;
var result = Validate(normalized);
if (!result.IsValid || result.License is null)
{
LastError = result.Error;
return result;
}
try
{
await SecureStorage.Default.SetAsync(SecureStorageKey, normalized);
}
catch
{
return ManualLicenseActivationResult.Failure("Impossibile salvare la licenza nell'archivio sicuro del dispositivo.");
}
_token = normalized;
Current = result.License;
LastError = null;
ObserveUtcNow();
return result;
}
public Task RemoveAsync()
{
SecureStorage.Default.Remove(SecureStorageKey);
_token = null;
Current = null;
LastError = null;
return Task.CompletedTask;
}
private void ValidateStoredToken()
{
if (string.IsNullOrWhiteSpace(_token))
{
Current = null;
LastError = null;
return;
}
var result = Validate(_token);
Current = result.License;
LastError = result.Error;
ObserveUtcNow();
}
private static ManualLicenseActivationResult Validate(string token)
{
try
{
var parts = token.Split('.');
if (parts.Length != 3 || parts[0] != "LME1")
{
return ManualLicenseActivationResult.Failure("Formato della licenza non riconosciuto.");
}
var envelope = Base64UrlDecode(parts[1]);
var signature = Base64UrlDecode(parts[2]);
using var rsa = RSA.Create();
rsa.ImportFromPem(PublicKeyPem);
if (!rsa.VerifyData(envelope, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
return ManualLicenseActivationResult.Failure("Firma della licenza non valida.");
}
var payloadBytes = DecryptEnvelope(envelope);
var payload = JsonSerializer.Deserialize<ManualLicensePayload>(payloadBytes, JsonOptions);
if (payload is null
|| payload.Version != 1
|| payload.Product != ProductName
|| !Guid.TryParse(payload.LicenseId, out _)
|| string.IsNullOrWhiteSpace(payload.FirstName)
|| string.IsNullOrWhiteSpace(payload.LastName)
|| string.IsNullOrWhiteSpace(payload.Email)
|| payload.ExpiresAtUtc <= payload.IssuedAtUtc)
{
return ManualLicenseActivationResult.Failure("Contenuto della licenza non valido.");
}
var license = new ManualLicenseInfo(
payload.LicenseId,
payload.FirstName.Trim(),
payload.LastName.Trim(),
payload.Email.Trim(),
payload.IssuedAtUtc,
payload.ExpiresAtUtc);
return license.ExpiresAtUtc <= GetEffectiveUtcNow()
? ManualLicenseActivationResult.Expired(license)
: ManualLicenseActivationResult.Success(license);
}
catch (Exception error) when (error is FormatException or CryptographicException or JsonException or ArgumentException)
{
return ManualLicenseActivationResult.Failure("Licenza danneggiata o non valida.");
}
}
private static byte[] DecryptEnvelope(byte[] envelope)
{
const int nonceLength = 12;
const int tagLength = 16;
if (envelope.Length <= nonceLength + tagLength)
{
throw new CryptographicException("Invalid license envelope.");
}
var nonce = envelope.AsSpan(0, nonceLength);
var tag = envelope.AsSpan(nonceLength, tagLength);
var ciphertext = envelope.AsSpan(nonceLength + tagLength);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(Convert.FromBase64String(EncryptionKeyBase64), tagLength);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
private static DateTimeOffset GetEffectiveUtcNow()
{
var now = DateTimeOffset.UtcNow;
var stored = Preferences.Default.Get(LastObservedUtcPreference, string.Empty);
var effective = DateTimeOffset.TryParse(stored, out var lastObserved) && lastObserved > now ? lastObserved : now;
if (!DateTimeOffset.TryParse(stored, out lastObserved) || effective > lastObserved)
{
Preferences.Default.Set(LastObservedUtcPreference, effective.ToString("O"));
}
return effective;
}
private static void ObserveUtcNow()
{
var now = DateTimeOffset.UtcNow;
var effective = GetEffectiveUtcNow();
Preferences.Default.Set(LastObservedUtcPreference, (effective > now ? effective : now).ToString("O"));
}
private sealed record ManualLicensePayload(
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("product")] string Product,
[property: JsonPropertyName("licenseId")] string LicenseId,
[property: JsonPropertyName("firstName")] string FirstName,
[property: JsonPropertyName("lastName")] string LastName,
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("issuedAtUtc")] DateTimeOffset IssuedAtUtc,
[property: JsonPropertyName("expiresAtUtc")] DateTimeOffset ExpiresAtUtc);
}
public sealed record ManualLicenseInfo(
string LicenseId,
string FirstName,
string LastName,
string Email,
DateTimeOffset IssuedAtUtc,
DateTimeOffset ExpiresAtUtc)
{
public string HolderName => $"{FirstName} {LastName}".Trim();
}
public sealed record ManualLicenseActivationResult(bool IsValid, bool IsExpired, ManualLicenseInfo? License, string? Error)
{
public static ManualLicenseActivationResult Success(ManualLicenseInfo license) => new(true, false, license, null);
public static ManualLicenseActivationResult Expired(ManualLicenseInfo license) => new(false, true, license, "La licenza è scaduta.");
public static ManualLicenseActivationResult Failure(string error) => new(false, false, null, error);
}