using System; using System.IO; using Cysharp.Threading.Tasks; using UnityEngine; namespace Ubisoft.Hotel.Device { public class StorageManager { private static readonly int BufferSize = 8192; // 8KB. Default for copy is 81920. default for reading is 4096 private static string s_dataPath; public enum OperationStatus { Ok = 0, NotFound = 1, PermissionError = 2, DiskFull = 3, Corrupted = 4, OtherError = 5 } public struct FileDescriptor { public string FilePath { get; } public bool IsVersioned { get; } public bool IsEncrypted { get; } public bool IsCompressed { get; } public bool HasBackup { get; } public FileDescriptor(string filePath, bool isVersioned = false, bool isEncrypted = false, bool isCompressed = false, bool hasBackup = false) { FilePath = filePath; IsVersioned = isVersioned; IsEncrypted = isEncrypted; IsCompressed = isCompressed; HasBackup = hasBackup; } } public struct ReadResult { public byte[] Bytes { get; } public OperationStatus Status { get; } public ReadResult(byte[] bytes, OperationStatus status) { Bytes = bytes; Status = status; } } public struct WriteResult { public long Bytes { get; } public OperationStatus Status { get; } public WriteResult(long bytes, OperationStatus status) { Bytes = bytes; Status = status; } } [RuntimeInitializeOnLoadMethod] static void OnRuntimeMethodLoad() { // Application.dataPath and Application.persistentDataPath can only be called from the main thread // We store their value in DATA_PATH at startup so that all methods in this manager can be executed outside the main thread if necessary #if UNITY_EDITOR s_dataPath = Application.dataPath + "/../.data"; // .data folder outside Assets #else s_dataPath = Application.persistentDataPath; #endif } #region Public API /// /// Calculates the path to data folder /// /// The path to the data folder, which points to .data on UnityEditor and to Application.persistentDataPath on other platforms public static string GetDataPath() { return s_dataPath; } /// /// Calculates the absolute path of a file, assuming that it's located in the app's persistent data folder /// /// The descriptor of the file /// The absolute path to the file, or null if there was an error public static string GetAbsolutePath(FileDescriptor fileDescriptor) { return GetAbsolutePath(GetVersionedPath(fileDescriptor)); } /// /// Calculates the absolute path of a file, assuming that it's located in the app's persistent data folder /// /// The path to the file, including its name and extension. This path is assumed to be relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) and may contain intermediate folders /// The absolute path to the file, or null if there was an error public static string GetAbsolutePath(string filePath) { return GetAbsolutePath(filePath, true); } /// /// Reads the contents of a file asynchronously /// /// The descriptor of the file to read /// A ReadResult object with the file contents (null if there was an error) and an OperationStatus indicating whether the operation failed or was successful public static async UniTask ReadFileAsync(FileDescriptor fileDescriptor) { string path = GetVersionedPath(fileDescriptor); // TODO Handle fileDescriptor.IsCompressed and fileDescriptor.IsEncrypted // TODO When we start storing metadata, we should compare it with the info in fileDescriptor param, since the consumer may be wrong when indicating if the file is encrypted, etc. return await ReadFileAsync(path); } /// /// Reads the contents of a file asynchronously /// /// The path of the file to read, including its filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether filePath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// A ReadResult object with the file contents (null if there was an error) and an OperationStatus indicating whether the operation failed or was successful public static async UniTask ReadFileAsync(string filePath, bool pathIsRelative = true) { byte[] buffer = null; OperationStatus status = OperationStatus.Ok; string path = GetAbsolutePath(filePath, pathIsRelative); path = HtSystemIO.NormalizePath(path); if (File.Exists(path)) { try { using (Stream streamRead = new FileStream(path, FileMode.Open, FileAccess.Read)) { buffer = new byte[streamRead.Length]; _ = await streamRead.ReadAsync(buffer, 0, (int)streamRead.Length); } } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error reading file {path}: {e.Message}"); buffer = null; if (e is NotSupportedException || e is InvalidOperationException) { // NotSupportedException: The stream does not support reading // InvalidOperationException: The stream is currently in use by a previous read operation status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } } else { DeviceLogger.Channel.LogWarning($"Error reading file: File {path} doesn't exist"); status = OperationStatus.NotFound; } return new ReadResult(buffer, status); } /// /// Writes data to a file asynchronously /// /// The data to write /// Whether fileContent is in UTF-8 format /// The descriptor of the file to write /// Set to true to override the file if it already exists. Otherwise data will be appended /// A WriteResult object with the amount of bytes written and an OperationStatus indicating whether the operation failed or was successful public static async UniTask WriteFileAsync(string fileContent, bool isUtf8, FileDescriptor fileDescriptor, bool allowOverride = false) { string path = GetVersionedPath(fileDescriptor); // TODO Handle fileDescriptor.IsCompressed and fileDescriptor.IsEncrypted return await WriteFileAsync(fileContent, isUtf8, path, true, allowOverride); } /// /// Writes data to a file asynchronously /// /// The data to write /// The descriptor of the file to write /// Set to true to override the file if it already exists. Otherwise data will be appended /// A WriteResult object with the amount of bytes written and an OperationStatus indicating whether the operation failed or was successful public static async UniTask WriteFileAsync(byte[] fileContent, FileDescriptor fileDescriptor, bool allowOverride = false) { string path = GetVersionedPath(fileDescriptor); // TODO Handle fileDescriptor.IsCompressed and fileDescriptor.IsEncrypted return await WriteFileAsync(fileContent, path, true, allowOverride); } /// /// Writes data to a file asynchronously /// /// The data to write /// Whether fileContent is in UTF-8 format /// The path of the file where the contents will be written, including the filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether destinationPath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// Set to true to override the file if it already exists. Otherwise data will be appended /// A WriteResult object with the amount of bytes written and an OperationStatus indicating whether the operation failed or was successful public static async UniTask WriteFileAsync(string fileContent, bool isUtf8, string destinationPath, bool pathIsRelative = true, bool allowOverride = false) { return await WriteFileAsync(HtString.GetBytes(fileContent, isUtf8), destinationPath, pathIsRelative, allowOverride); } /// /// Writes data to a file asynchronously /// /// The data to write /// The path of the file where the contents will be written, including its filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether destinationPath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// Set to true to override the file if it already exists. Otherwise data will be appended /// A WriteResult object with the amount of bytes written and an OperationStatus indicating whether the operation failed or was successful public static async UniTask WriteFileAsync(byte[] fileContent, string destinationPath, bool pathIsRelative = true, bool allowOverride = false) { long bytesWritten = 0; OperationStatus status = OperationStatus.Ok; string path = GetAbsolutePath(destinationPath, pathIsRelative); FileMode mode = allowOverride ? FileMode.Create : FileMode.Append; try { // Create the directory if it doesn't exist _ = Directory.CreateDirectory(Path.GetDirectoryName(path)); // Write the file using (FileStream targetStream = File.Open(path, mode)) { _ = targetStream.Seek(0, SeekOrigin.End); await targetStream.WriteAsync(fileContent, 0, fileContent.Length); bytesWritten = targetStream.Length; } } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error writing file: {e.Message}"); if (GetFreeDiskSpace() < fileContent.Length) { status = OperationStatus.DiskFull; } else if (e is NotSupportedException || e is InvalidOperationException) { // NotSupportedException: The stream does not support writing // InvalidOperationException: The stream is currently in use by a previous write operation status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } return new WriteResult(bytesWritten, status); } /// /// Copies the contents from a file to a new file asynchronously /// /// The descriptor of the source file to copy /// The descriptor of the destination file /// Set to true to override the destination file if it already exists. Otherwise data will be appended /// A WriteResult object with the amount of bytes copied and an OperationStatus indicating whether the copy was successful or there was an error public static async UniTask CopyFileAsync(FileDescriptor fromFile, FileDescriptor toFile, bool allowOverride = false) { string fromPath = fromFile.IsVersioned ? Path.Combine(Application.version, fromFile.FilePath) : fromFile.FilePath; string toPath = toFile.IsVersioned ? Path.Combine(Application.version, toFile.FilePath) : toFile.FilePath; return await CopyFileAsync(fromPath, toPath, true, allowOverride); } /// /// Copies the contents from a file to a new file asynchronously /// /// The path of the source file to copy, including its filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// The path of the destination file, including its filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether fromFilePath and toFilePath are absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// Set to true to override the destination file if it already exists. Otherwise data will be appended /// A WriteResult object with the amount of bytes copied and an OperationStatus indicating whether the copy was successful or there was an error public static async UniTask CopyFileAsync(string fromFilePath, string toFilePath, bool pathsAreRelative = true, bool allowOverride = false) { long bytesWritten = 0; OperationStatus status = OperationStatus.Ok; string sourcePath = pathsAreRelative ? GetAbsolutePath(fromFilePath) : fromFilePath; string destPath = pathsAreRelative ? GetAbsolutePath(toFilePath) : toFilePath; if (!File.Exists(sourcePath)) { DeviceLogger.Channel.LogWarning($"Error creating file copy: Source file {sourcePath} doesn't exist"); return new WriteResult(bytesWritten, OperationStatus.NotFound); } if (!allowOverride && File.Exists(destPath)) { DeviceLogger.Channel.LogWarning($"Error creating file copy: Destination file {destPath} already exists and override is not allowed"); return new WriteResult(bytesWritten, OperationStatus.OtherError); } long bytesToWrite = 0; try { using (var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, true)) { bytesToWrite = sourceStream.Length; // Create the directory if it doesn't exist _ = Directory.CreateDirectory(Path.GetDirectoryName(destPath)); using (var targetStream = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.Read, BufferSize, true)) { await sourceStream.CopyToAsync(targetStream, BufferSize); bytesWritten = targetStream.Length; } } } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error creating file copy: {e.Message}"); if (GetFreeDiskSpace() < bytesToWrite) { status = OperationStatus.DiskFull; } else if (e is NotSupportedException) { status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } return new WriteResult(bytesWritten, status); } /// /// Removes a file from disk /// /// The descriptor of the file to delete /// An OperationStatus indicating whether the deletion was successful or there was an error public static OperationStatus DeleteFile(FileDescriptor fileDescriptor) { string filePath = fileDescriptor.IsVersioned ? Path.Combine(Application.version, fileDescriptor.FilePath) : fileDescriptor.FilePath; return DeleteFile(filePath, true); } /// /// Removes a file from disk /// /// The path of the file to delete, including its name and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether filePath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// An OperationStatus indicating whether the deletion was successful or there was an error public static OperationStatus DeleteFile(string filePath, bool pathIsRelative = true) { string path = GetAbsolutePath(filePath, pathIsRelative); OperationStatus status = OperationStatus.Ok; if (File.Exists(path)) { try { File.Delete(path); } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error deleting file: {e.Message}"); if (e is ArgumentNullException || e is ArgumentException || e is DirectoryNotFoundException || e is PathTooLongException || e is NotSupportedException) { status = OperationStatus.NotFound; } else if (e is UnauthorizedAccessException || e is IOException) { status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } } else { DeviceLogger.Channel.LogWarning($"Error deleting file: Path {path} doesn't exist"); status = OperationStatus.NotFound; } return status; } /// /// Removes a folder from disk /// /// The path of the folder to delete. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether dirPath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// public static OperationStatus DeleteDirectory(string dirPath, bool pathIsRelative = true) { string path = GetAbsolutePath(dirPath, pathIsRelative); OperationStatus status = OperationStatus.Ok; if (Directory.Exists(path)) { try { Directory.Delete(path); } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error deleting directory: {e.Message}"); if (e is ArgumentNullException || e is ArgumentException || e is DirectoryNotFoundException || e is PathTooLongException || e is NotSupportedException) { status = OperationStatus.NotFound; } else if (e is UnauthorizedAccessException || e is IOException) { status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } } else { DeviceLogger.Channel.LogWarning($"Error deleting directory: Path {path} doesn't exist"); status = OperationStatus.NotFound; } return status; } /// /// Checks whether a file exists /// /// The descriptor of the file /// True if the file exists public static bool FileExists(FileDescriptor fileDescriptor) { string filePath = fileDescriptor.IsVersioned ? Path.Combine(Application.version, fileDescriptor.FilePath) : fileDescriptor.FilePath; return FileExists(filePath, true); } /// /// Checks whether a file exists /// /// The path to the file, including its name and extension,. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether filepath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// True if the file exists public static bool FileExists(string filepath, bool pathIsRelative = true) { string path = GetAbsolutePath(filepath, pathIsRelative); return File.Exists(path); } /// /// Calculates the disk space that is currently free on the device /// /// The disk space available in bytes public static long GetFreeDiskSpace() { return NativeDevice.Instance.GetFreeDiskSpace(GetDataPath()); } #endregion #region Synchronous API // This API is meant to be used internally, when files are small enough to be read or written more efficiently in a synchronous way // The corresponding research is yet to be made, so for now this section is not used /// /// Reads the contents of a file synchronously /// /// The path of the file to read, including the filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether filePath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// A ReadResult object with the file contents (null if there was an error) and an OperationStatus indicating whether the operation failed or was successful private static ReadResult ReadFile(string filePath, bool pathIsRelative = true) { byte[] buffer = null; OperationStatus status = OperationStatus.Ok; string path = GetAbsolutePath(filePath, pathIsRelative); if (File.Exists(path)) { try { buffer = File.ReadAllBytes(path); } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error reading file {path}: {e.Message}"); buffer = null; if (e is NotSupportedException || e is InvalidOperationException) { // NotSupportedException: The stream does not support reading // InvalidOperationException: The stream is currently in use by a previous read operation status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } } else { DeviceLogger.Channel.LogWarning($"Error reading file: File {path} doesn't exist"); status = OperationStatus.NotFound; } return new ReadResult(buffer, status); } /// /// Writes data to a file synchronously /// /// The data to write /// Whether fileContent is in UTF-8 format /// The path of the file where the contents will be written, including the filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether destinationPath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// Set to true to override the file if it already exists. Otherwise the write operation will fail /// A WriteResult object with the amount of bytes written and an OperationStatus indicating whether the operation failed or was successful private static WriteResult WriteFile(string fileContent, bool isUtf8, string destinationPath, bool pathIsRelative = true, bool allowOverride = false) { return WriteFile(HtString.GetBytes(fileContent, isUtf8), destinationPath, pathIsRelative, allowOverride); } /// /// Writes data to a file synchronously /// /// The data to write /// The path of the file where the contents will be written, including the filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether destinationPath is absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// Set to true to override the file if it already exists. Otherwise the write operation will fail /// A WriteResult object with the amount of bytes written and an OperationStatus indicating whether the operation failed or was successful private static WriteResult WriteFile(byte[] fileContent, string destinationPath, bool pathIsRelative = true, bool allowOverride = false) { long bytesWritten = 0; OperationStatus status = OperationStatus.Ok; string path = GetAbsolutePath(destinationPath, pathIsRelative); if (!allowOverride && File.Exists(path)) { DeviceLogger.Channel.LogWarning($"Error writing file: {path} already exists and override is not allowed"); return new WriteResult(bytesWritten, OperationStatus.OtherError); } try { // Create the directory if it doesn't exist _ = Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllBytes(path, fileContent); bytesWritten = fileContent.Length; } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error writing file: {e.Message}"); if (GetFreeDiskSpace() < fileContent.Length) { status = OperationStatus.DiskFull; } else if (e is NotSupportedException || e is InvalidOperationException) { // NotSupportedException: The stream does not support writing // InvalidOperationException: The stream is currently in use by a previous write operation status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } return new WriteResult(bytesWritten, status); } /// /// Copies the contents from a file to a new file synchronously /// /// The path of the source file to copy, including its filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// The path of the destination file, including its filename and extension. It can be absolute or relative to Application.persistentDataPath ('%project_folder%/.data' on UnityEditor) /// Whether fromFilePath and toFilePath are absolute or relative to Application.persistentDataPath (%project_folder%/.data on UnityEditor) /// Set to true to override the file if it already exists. /// A WriteResult object with the amount of bytes copied and an OperationStatus indicating whether the copy was successful or there was an error/// A tuple with the amount of bytes ciopied and an OperationStatus indicating whether the copy was successful or there was an error private static WriteResult CopyFile(string fromFilePath, string toFilePath, bool pathsAreRelative = true, bool allowOverride = false) { long bytesWritten = 0; OperationStatus status = OperationStatus.Ok; string sourcePath = pathsAreRelative ? GetAbsolutePath(fromFilePath) : fromFilePath; string destPath = pathsAreRelative ? GetAbsolutePath(toFilePath) : toFilePath; if (!File.Exists(sourcePath)) { DeviceLogger.Channel.LogWarning($"Error creating file copy: Source file {sourcePath} doesn't exist"); return new WriteResult(bytesWritten, OperationStatus.NotFound); } if (!allowOverride && File.Exists(destPath)) { DeviceLogger.Channel.LogWarning($"Error creating file copy: Destination file {destPath} already exists and override is not allowed"); return new WriteResult(bytesWritten, OperationStatus.OtherError); } long bytesToWrite = 0; try { using (var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, false)) { bytesToWrite = sourceStream.Length; // Create the directory if it doesn't exist _ = Directory.CreateDirectory(Path.GetDirectoryName(destPath)); using (var targetStream = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.Read, BufferSize, false)) { sourceStream.CopyTo(targetStream, BufferSize); bytesWritten = targetStream.Length; } } } catch (Exception e) { DeviceLogger.Channel.LogWarning($"Error creating file copy: {e.Message}"); if (GetFreeDiskSpace() < bytesToWrite) { status = OperationStatus.DiskFull; } else if (e is NotSupportedException) { status = OperationStatus.PermissionError; } else { status = OperationStatus.OtherError; } } return new WriteResult(bytesWritten, status); } #endregion #region Helpers private static string GetAbsolutePath(string filepath, bool pathIsRelative) { string path = null; try { path = pathIsRelative ? Path.Combine(GetDataPath(), filepath) : filepath; path = HtSystemIO.NormalizePath(path); } catch (ArgumentNullException) { DeviceLogger.Channel.LogError("Invalid path (argument is null)"); } catch (ArgumentException e) { DeviceLogger.Channel.LogError($"Invalid path (argument contains invalid characters): {e.Message}"); } return path; } private static string GetVersionedPath(FileDescriptor fileDescriptor) { return fileDescriptor.IsVersioned ? Path.Combine(Application.version, fileDescriptor.FilePath) : fileDescriptor.FilePath; } #endregion } }