using System; using System.Collections.Generic; using System.IO; using System.Text; using Cysharp.Threading.Tasks; using Force.Crc32; using Newtonsoft.Json; using Ubisoft.Hotel.Device; using UnityEngine; namespace Ubisoft.Hotel.Cache { public class CacheManager { private const string DEFAULT_CACHE_FOLDER = "cache"; private const string DATA_FILE_EXTENSION = ".json"; private const string METADATA_FILE_EXTENSION = ".cache"; public static string RootFolder { get; private set; } = DEFAULT_CACHE_FOLDER; public static string GameVersion { get; private set; } private static CacheManager s_instance; private CacheSystemMetadata SystemMetadata { get; set; } private readonly List m_drivers = new List(); private bool m_initialized = false; private CacheManager() { } #region Public API public static CacheManager GetInstance() { return s_instance ?? (s_instance = new CacheManager()); } public async UniTask InitAsync() { if (!m_initialized) { m_initialized = true; await InitAsync(Application.version); } } public async UniTask InitAsync(string gameVersion, string rootFolder = DEFAULT_CACHE_FOLDER) { RootFolder = rootFolder; GameVersion = gameVersion; SystemMetadata = await GetSystemMetadataAsync(); if(SystemMetadata == null) { SystemMetadata = new CacheSystemMetadata(); SystemMetadata.RootSubfolders = new ListOfString(); } Cleanup(); } public async UniTask RegisterDriverAsync(AbstractCacheDriver driver) { m_drivers.Add(driver); // Add the new root subfolder to Cache Manager's metadata if (!SystemMetadata.RootSubfolders.Contains(driver.RootFolder)) { SystemMetadata.RootSubfolders.Add(driver.RootFolder); await UpdateSystemMetadataAsync(); } await driver.ApplyGameVersionAsync(GameVersion); } public void UnRegisterDriver(AbstractCacheDriver driver) { m_drivers.Remove(driver); } public void InvalidateAll() { foreach (var driver in m_drivers) { driver.InvalidateAll(); } } /// /// Applies the current game version to all registered cache drivers /// The value will be updated in each driver's metadata, and the caches that they manage may be invalidated /// public async UniTask ApplyGameVersionAsync() { await ApplyGameVersionAsync(GameVersion); } /// /// Applies the provided gameVersion to all registered cache drivers /// The value will be updated in each driver's metadata, and the caches that they manage may be invalidated /// /// The game version to apply public async UniTask ApplyGameVersionAsync(string gameVersion) { foreach (var driver in m_drivers) { await driver.ApplyGameVersionAsync(gameVersion); } } public static async UniTask GetGlobalMetadataAsync(string rootSubfolder) { var fd = BuildFileDescriptor($"{rootSubfolder}/{Constants.GlobalMetadataFile}"); if (StorageManager.FileExists(fd)) { var result = await StorageManager.ReadFileAsync(fd); if (result.Status == StorageManager.OperationStatus.Ok) { string json = Encoding.UTF8.GetString(result.Bytes); return JsonConvert.DeserializeObject(json); } } return null; } public static async UniTask UpdateGlobalMetadataAsync(string rootFolder, CacheGlobalMetadata globalMetadata) { var fd = BuildFileDescriptor($"{rootFolder}/{Constants.GlobalMetadataFile}"); string jsonData = JsonConvert.SerializeObject(globalMetadata); var result = await StorageManager.WriteFileAsync(jsonData, true, fd, true); if (result.Status != StorageManager.OperationStatus.Ok) { // TODO What to do if this fails? CacheLogger.Channel.LogError($"Failed to update global metadata at {RootFolder}/{rootFolder}"); } } /// /// Returns an object containing the cached file's metadata /// /// The file path without extension and relative to RootFolder /// An object containing the file's metadata public static async UniTask GetFileMetadataAsync(string filepath, string filter = "") { string filterSuffix = string.IsNullOrEmpty(filter) ? "" : $".{filter}"; var fd = BuildFileDescriptor($"{filepath}{filterSuffix}{METADATA_FILE_EXTENSION}"); if(StorageManager.FileExists(fd)) { var result = await StorageManager.ReadFileAsync(fd); if(result.Status == StorageManager.OperationStatus.Ok) { string json = Encoding.UTF8.GetString(result.Bytes); return JsonConvert.DeserializeObject(json); } } return null; } /// /// Reads the content of a cached file /// /// The file path without extension and relative to RootFolder /// A string with the file contenbs public static async UniTask GetDataAsync(string filepath, string filter = "") { // TODO Handle slots string filterSuffix = string.IsNullOrEmpty(filter) ? "" : $".{filter}"; var fd = BuildFileDescriptor($"{filepath}{filterSuffix}{DATA_FILE_EXTENSION}"); if (StorageManager.FileExists(fd)) { var result = await StorageManager.ReadFileAsync(fd); if (result.Status == StorageManager.OperationStatus.Ok) { return Encoding.UTF8.GetString(result.Bytes); } else { CacheLogger.Channel.LogWarning($"Failed to read file {fd.FilePath}: {result.Status}"); } } else { CacheLogger.Channel.LogDebug($"File {fd.FilePath} doesn't exist"); } return null; } /// /// Replaces local data with data passed as parameter /// /// The path of the file to override, without extension and relative to RootFolder /// The data to write public static async UniTask UpdateDataAsync(string filepath, string jsonData, string filter = "") { // TODO Handle slots // Write data string filterSuffix = string.IsNullOrEmpty(filter) ? "" : $".{filter}"; var fd = BuildFileDescriptor($"{filepath}{filterSuffix}{DATA_FILE_EXTENSION}"); var result = await StorageManager.WriteFileAsync(jsonData, true, fd, true); if(result.Status == StorageManager.OperationStatus.Ok) { // Write metadata string crc = ComputeCRCFromString(jsonData); CacheFileMetadata metadata = new CacheFileMetadata() { NumSlots = 1, ActiveSlot = 1, Crc = crc, Timestamp = DateTime.UtcNow.Ticks }; string jsonMetadata = JsonConvert.SerializeObject(metadata); fd = BuildFileDescriptor($"{filepath}{filterSuffix}{METADATA_FILE_EXTENSION}"); result = await StorageManager.WriteFileAsync(jsonMetadata, true, fd, true); if (result.Status != StorageManager.OperationStatus.Ok) { CacheLogger.Channel.LogWarning($"Failed to update metadata! File: {fd.FilePath}"); } } else { CacheLogger.Channel.LogWarning($"Failed to update cache! File: {fd.FilePath}"); } } /// /// Invalidates (deletes) a cached file (all the slots) /// /// The path of the file to invalidate, without extension and relative to RootFolder public static void InvalidateFile(string filepath, string filter = "") { // TODO Handle slots string filterSuffix = string.IsNullOrEmpty(filter) ? "" : $".{filter}"; var fd = BuildFileDescriptor($"{filepath}{filterSuffix}{DATA_FILE_EXTENSION}"); if (StorageManager.FileExists(fd)) { StorageManager.DeleteFile(fd); } fd = BuildFileDescriptor($"{filepath}{filterSuffix}{METADATA_FILE_EXTENSION}"); if (StorageManager.FileExists(fd)) { StorageManager.DeleteFile(fd); } } /// /// Invalidates (deletes) a specific slot of a cached file /// /// The path of the file to invalidate, without extension and relative to RootFolder /// The slot number to invalidate public static void InvalidateFile(string filepath, int slot) { // TODO Handle slots var fd = BuildFileDescriptor(filepath + DATA_FILE_EXTENSION); if (StorageManager.FileExists(fd)) { StorageManager.DeleteFile(fd); } fd = BuildFileDescriptor(filepath + METADATA_FILE_EXTENSION); if (StorageManager.FileExists(fd)) { StorageManager.DeleteFile(fd); } } /// /// Invalidates (deletes) all files and folders in dirpath, except for the cache's global metadata file /// /// The folder to invalidate, relative to RootFolder public static void InvalidateAllFiles(string dirpath) { string cacheFolder = GetAbsolutePath(dirpath); if (Directory.Exists(cacheFolder)) { CacheLogger.Channel.LogDebug($"Deleting all files under {cacheFolder}"); DirectoryInfo info = new DirectoryInfo(cacheFolder); foreach (FileInfo file in info.EnumerateFiles()) { if (file.Name != Constants.GlobalMetadataFile) { file.Delete(); } } foreach (DirectoryInfo dir in info.EnumerateDirectories()) { dir.Delete(true); } } } public static void InvalidateDirectory(string dirpath) { CacheLogger.Channel.LogDebug($"Deleting folder {dirpath}"); StorageManager.DeleteDirectory(dirpath); } /// /// Invalidates (deletes) all caches managed by CacheManager /// public static void InvalidateAllCaches() { foreach (string dir in GetInstance().SystemMetadata.RootSubfolders) { InvalidateAllFiles(dir); } } #endregion #region Private Methods private static StorageManager.FileDescriptor BuildFileDescriptor(string filepath) { return new StorageManager.FileDescriptor($"{RootFolder}/{filepath}"); } private static string GetAbsolutePath(string path) { return StorageManager.GetAbsolutePath($"{RootFolder}/{path}"); } private static async UniTask GetSystemMetadataAsync() { var fd = BuildFileDescriptor(Constants.SystemMetadataFile); if (StorageManager.FileExists(fd)) { var result = await StorageManager.ReadFileAsync(fd); if (result.Status == StorageManager.OperationStatus.Ok) { string json = Encoding.UTF8.GetString(result.Bytes); return JsonConvert.DeserializeObject(json); } } return null; } private async UniTask UpdateSystemMetadataAsync() { var fd = BuildFileDescriptor(Constants.SystemMetadataFile); string jsonData = JsonConvert.SerializeObject(SystemMetadata); var result = await StorageManager.WriteFileAsync(jsonData, true, fd, true); if (result.Status != StorageManager.OperationStatus.Ok) { // TODO What to do if this fails? CacheLogger.Channel.LogError($"Failed to update system metadata at {RootFolder}"); } } private void Cleanup() { // TODO Remove unused files } private static string ComputeCRCFromString(string data) { byte[] bytes = Encoding.UTF8.GetBytes(data); uint crc = Crc32Algorithm.Compute(bytes); return crc.ToString("X8"); } private static string ComputeCrcFromFile(string filePath) { uint crc = ComputeCRC32FromFile(filePath); return crc.ToString("X8"); } private static uint ComputeCRC32FromFile(string filePath) { var crc = 0u; using (var f = File.OpenRead(filePath)) { var buffer = new byte[65536]; while (true) { var currentBlockSize = f.Read(buffer, 0, buffer.Length); if (currentBlockSize == 0) break; crc = Crc32Algorithm.Append(crc, buffer, 0, currentBlockSize); } } return crc; } #endregion } }