// /*************************************************************************** // Aaru Data Preservation Suite // ---------------------------------------------------------------------------- // // Filename : Statistics.cs // Author(s) : Natalia Portillo // // Component : Core algorithms. // // --[ Description ] ---------------------------------------------------------- // // Handles usage statistics. // // --[ License ] -------------------------------------------------------------- // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // ---------------------------------------------------------------------------- // Copyright © 2011-2025 Natalia Portillo // ****************************************************************************/ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Aaru.CommonTypes.Interop; using Aaru.CommonTypes.Metadata; using Aaru.Database; using Aaru.Database.Models; using Aaru.Logging; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Sentry; using Device = Aaru.Devices.Device; using MediaType = Aaru.CommonTypes.MediaType; using OperatingSystem = Aaru.Database.Models.OperatingSystem; using Version = Aaru.Database.Models.Version; namespace Aaru.Core; /// Handles anonymous usage statistics public static class Statistics { const string MODULE_NAME = "Stats"; /// Statistics file semaphore static bool _submitStatsLock; /// Loads saved statistics from disk public static void LoadStats() { try { using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); if(Settings.Settings.Current.Stats == null) return; ctx.OperatingSystems.Add(new OperatingSystem { Name = DetectOS.GetRealPlatformID().ToString(), Synchronized = false, Version = DetectOS.GetVersion(), Count = 1 }); ctx.Versions.Add(new Version { Name = CommonTypes.Interop.Version.GetInformationalVersion(), Synchronized = false, Count = 1 }); ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Saves statistics to disk public static async Task SaveStatsAsync() { try { await using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); await ctx.SaveChangesAsync(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } if(Settings.Settings.Current.Stats is { ShareStats: true }) await SubmitStatsAsync(); } /// Submits statistics to Aaru.Server static async Task SubmitStatsAsync() { await using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); try { if(_submitStatsLock) return; _submitStatsLock = true; var dto = new StatsDto(); AddStats(ctx.Commands, out List nameValueStats); if(nameValueStats?.Count > 0) dto.Commands = nameValueStats; AddStats(ctx.Filesystems, out nameValueStats); if(nameValueStats?.Count > 0) dto.Filesystems = nameValueStats; AddStats(ctx.Filters, out nameValueStats); if(nameValueStats?.Count > 0) dto.Filters = nameValueStats; AddStats(ctx.MediaFormats, out nameValueStats); if(nameValueStats?.Count > 0) dto.MediaFormats = nameValueStats; AddStats(ctx.Archives, out nameValueStats); if(nameValueStats?.Count > 0) dto.Archives = nameValueStats; AddStats(ctx.Partitions, out nameValueStats); if(nameValueStats?.Count > 0) dto.Partitions = nameValueStats; AddStats(ctx.Versions, out nameValueStats); if(nameValueStats?.Count > 0) dto.Versions = nameValueStats; if(ctx.Medias.Any(static c => !c.Synchronized)) { dto.Medias = []; foreach(string media in ctx.Medias.Where(static c => !c.Synchronized) .Select(static c => c.Type) .Distinct()) { if(ctx.Medias.Any(c => !c.Synchronized && c.Type == media && c.Real)) { dto.Medias.Add(new MediaStats { real = true, MediaType = media, Value = ctx.Medias.LongCount(c => !c.Synchronized && c.Type == media && c.Real) }); } if(ctx.Medias.Any(c => !c.Synchronized && c.Type == media && !c.Real)) { dto.Medias.Add(new MediaStats { real = false, MediaType = media, Value = ctx.Medias.LongCount(c => !c.Synchronized && c.Type == media && !c.Real) }); } } } if(ctx.SeenDevices.Any(static c => !c.Synchronized)) { dto.Devices = []; foreach(DeviceStat device in ctx.SeenDevices.Where(static c => !c.Synchronized)) { dto.Devices.Add(new DeviceStats { Bus = device.Bus, Manufacturer = device.Manufacturer, ManufacturerSpecified = device.Manufacturer is not null, Model = device.Model, Revision = device.Revision }); } } AddOperatingSystem(ctx.OperatingSystems, out List osStats); if(nameValueStats?.Count > 0) dto.OperatingSystems = osStats; AddOperatingSystem(ctx.RemoteApplications, out osStats); if(nameValueStats?.Count > 0) dto.RemoteApplications = osStats; AddStats(ctx.RemoteArchitectures, out nameValueStats); if(nameValueStats?.Count > 0) dto.RemoteArchitectures = nameValueStats; AddOperatingSystem(ctx.RemoteOperatingSystems, out osStats); if(nameValueStats?.Count > 0) dto.RemoteOperatingSystems = osStats; #if DEBUG Console.WriteLine(Localization.Core.Uploading_statistics); #else Aaru.Logging.AaruLogging.Debug(MODULE_NAME, Localization.Core.Uploading_statistics); #endif using StringContent jsonContent = new(JsonSerializer.Serialize(dto, typeof(StatsDto), StatsDtoContext.Default), Encoding.UTF8, "application/json"); var client = new HttpClient(); client.BaseAddress = new Uri("https://www.aaru.app"); client.DefaultRequestHeaders.Add("User-Agent", $"Aaru {typeof(Version).Assembly.GetName().Version}"); using HttpResponseMessage response = await client.PostAsync("/api/uploadstatsv2", jsonContent); if(response.StatusCode != HttpStatusCode.OK) return; string result = await response.Content.ReadAsStringAsync(); if(result != "ok") return; await UpdateStatsAsync(ctx.Commands); await UpdateStatsAsync(ctx.Filesystems); await UpdateStatsAsync(ctx.Filters); await UpdateStatsAsync(ctx.MediaFormats); await UpdateStatsAsync(ctx.Archives); await UpdateStatsAsync(ctx.Partitions); await UpdateStatsAsync(ctx.Versions); if(ctx.Medias.Any(static c => !c.Synchronized)) { foreach(string media in ctx.Medias.Where(static c => !c.Synchronized) .Select(static c => c.Type) .Distinct()) { if(ctx.Medias.Any(c => !c.Synchronized && c.Type == media && c.Real)) { Database.Models.Media existing = await ctx.Medias.FirstOrDefaultAsync(c => c.Synchronized && c.Type == media && c.Real) ?? new Database.Models.Media { Synchronized = true, Type = media, Real = true }; existing.Count += (ulong)ctx.Medias.LongCount(c => !c.Synchronized && c.Type == media && c.Real); ctx.Medias.Update(existing); ctx.Medias.RemoveRange(ctx.Medias.Where(c => !c.Synchronized && c.Type == media && c.Real)); } if(!ctx.Medias.Any(c => !c.Synchronized && c.Type == media && !c.Real)) continue; { Database.Models.Media existing = await ctx.Medias.FirstOrDefaultAsync(c => c.Synchronized && c.Type == media && !c.Real) ?? new Database.Models.Media { Synchronized = true, Type = media, Real = false }; existing.Count += (ulong)ctx.Medias.LongCount(c => !c.Synchronized && c.Type == media && !c.Real); ctx.Medias.Update(existing); ctx.Medias.RemoveRange(ctx.Medias.Where(c => !c.Synchronized && c.Type == media && !c.Real)); } } } if(ctx.SeenDevices.Any(static c => !c.Synchronized)) { foreach(DeviceStat device in ctx.SeenDevices.Where(static c => !c.Synchronized)) { device.Synchronized = true; ctx.Update(device); } } await UpdateOperatingSystemAsync(ctx.OperatingSystems); await UpdateOperatingSystemAsync(ctx.RemoteApplications); await UpdateStatsAsync(ctx.RemoteArchitectures); await UpdateOperatingSystemAsync(ctx.RemoteOperatingSystems); await ctx.SaveChangesAsync(); } catch(WebException ex) { // Can't connect to the server, do nothing SentrySdk.CaptureException(ex); } catch(DbUpdateConcurrencyException ex) { // Ignore db concurrency errors SentrySdk.CaptureException(ex); } catch(Exception ex) { SentrySdk.CaptureException(ex); #if DEBUG _submitStatsLock = false; if(Debugger.IsAttached) throw; #endif } _submitStatsLock = false; } static async Task UpdateOperatingSystemAsync(DbSet source) where T : BaseOperatingSystem, new() { if(!source.Any(static c => !c.Synchronized)) return; foreach(string name in source.Where(static c => !c.Synchronized).Select(static c => c.Name).Distinct()) { foreach(string version in source.Where(c => !c.Synchronized && c.Name == name) .Select(static c => c.Version) .Distinct()) { T existing = await source.FirstOrDefaultAsync(c => c.Synchronized && c.Name == name && c.Version == version) ?? new T { Synchronized = true, Version = version, Name = name }; existing.Count += (ulong)source.LongCount(c => !c.Synchronized && c.Name == name && c.Version == version); source.Update(existing); source.RemoveRange(source.Where(c => !c.Synchronized && c.Name == name && c.Version == version)); } } } static async Task UpdateStatsAsync(DbSet source) where T : NameCountModel, new() { if(!source.Any(static c => !c.Synchronized)) return; foreach(string nvs in source.Where(static c => !c.Synchronized).Select(static c => c.Name).Distinct()) { T existing = await source.FirstOrDefaultAsync(c => c.Synchronized && c.Name == nvs) ?? new T { Name = nvs, Synchronized = true }; existing.Count += (ulong)source.LongCount(c => !c.Synchronized && c.Name == nvs); source.Update(existing); source.RemoveRange(source.Where(c => !c.Synchronized && c.Name == nvs)); } } static void AddOperatingSystem(IQueryable source, out List destination) { destination = []; foreach(string remoteOsName in source.Where(static c => !c.Synchronized).Select(static c => c.Name).Distinct()) { foreach(string remoteOsVersion in source.Where(c => !c.Synchronized && c.Name == remoteOsName) .Select(static c => c.Version) .Distinct()) { destination.Add(new OsStats { name = remoteOsName, version = remoteOsVersion, Value = source.LongCount(c => !c.Synchronized && c.Name == remoteOsName && c.Version == remoteOsVersion) }); } } } static void AddStats(IQueryable source, out List destination) { destination = []; if(!source.Any(static c => !c.Synchronized)) return; foreach(string nvs in source.Where(static c => !c.Synchronized).Select(static c => c.Name).Distinct()) { destination.Add(new NameValueStats { name = nvs, Value = source.LongCount(c => !c.Synchronized && c.Name == nvs) }); } } /// Adds the execution of a command to statistics /// Command public static void AddCommand(string command) { if(string.IsNullOrWhiteSpace(command)) return; if(Settings.Settings.Current.Stats is not { DeviceStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.Commands.Add(new Command { Name = command, Synchronized = false, Count = 1 }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Adds a new filesystem to statistics /// Filesystem name public static void AddFilesystem(string filesystem) { if(string.IsNullOrWhiteSpace(filesystem)) return; if(Settings.Settings.Current.Stats is not { FilesystemStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.Filesystems.Add(new Filesystem { Name = filesystem, Synchronized = false, Count = 1 }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Adds a new partition scheme to statistics /// Partition scheme name internal static void AddPartition(string partition) { if(string.IsNullOrWhiteSpace(partition)) return; if(Settings.Settings.Current.Stats is not { PartitionStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.Partitions.Add(new Partition { Name = partition, Synchronized = false, Count = 1 }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Adds a new filter to statistics /// Filter name public static void AddFilter(string filter) { if(string.IsNullOrWhiteSpace(filter)) return; if(Settings.Settings.Current.Stats is not { FilterStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.Filters.Add(new Filter { Name = filter, Synchronized = false, Count = 1 }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Ads a new media image to statistics /// Media image name public static void AddMediaFormat(string format) { if(string.IsNullOrWhiteSpace(format)) return; if(Settings.Settings.Current.Stats is not { MediaImageStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.MediaFormats.Add(new MediaFormat { Name = format, Synchronized = false, Count = 1 }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Adds a new device to statistics /// Device public static void AddDevice(Device dev) { if(Settings.Settings.Current.Stats is not { DeviceStats: true }) return; string deviceBus; if(dev.IsUsb) deviceBus = "USB"; else if(dev.IsFireWire) deviceBus = "FireWire"; else deviceBus = dev.Type.ToString(); using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); if(ctx.SeenDevices.Any(d => d.Manufacturer == dev.Manufacturer && d.Model == dev.Model && d.Revision == dev.FirmwareRevision && d.Bus == deviceBus)) return; ctx.SeenDevices.Add(new DeviceStat { Bus = deviceBus, Manufacturer = dev.Manufacturer, Model = dev.Model, Revision = dev.FirmwareRevision, Synchronized = false }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Adds a new media type to statistics /// Media type /// Set if media was found on a real device, otherwise found on a media image public static void AddMedia(MediaType type, bool real) { if(Settings.Settings.Current.Stats is not { MediaStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.Medias.Add(new Database.Models.Media { Real = real, Synchronized = false, Type = type.ToString(), Count = 1 }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Adds a new remote to statistics public static void AddRemote(string serverApplication, string serverVersion, string serverOperatingSystem, string serverOperatingSystemVersion, string serverArchitecture) { if(Settings.Settings.Current.Stats is not { MediaStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.RemoteApplications.Add(new RemoteApplication { Count = 1, Name = serverApplication, Synchronized = false, Version = serverVersion }); ctx.RemoteArchitectures.Add(new RemoteArchitecture { Count = 1, Name = serverArchitecture, Synchronized = false }); ctx.RemoteOperatingSystems.Add(new RemoteOperatingSystem { Count = 1, Name = serverOperatingSystem, Synchronized = false, Version = serverOperatingSystemVersion }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } /// Ads a new archive to statistics /// Archive format name public static void AddArchiveFormat(string format) { if(string.IsNullOrWhiteSpace(format)) return; if(Settings.Settings.Current.Stats is not { MediaStats: true }) return; using var ctx = AaruContext.Create(Settings.Settings.LocalDbPath); ctx.Archives.Add(new Archive { Name = format, Synchronized = false, Count = 1 }); try { ctx.SaveChanges(); } catch(SqliteException ex) { AaruLogging.Debug(MODULE_NAME, Localization.Core.Exception_while_trying_to_save_statistics); AaruLogging.Exception(ex, Localization.Core.Exception_while_trying_to_save_statistics); } } }