// Copyright (c) 2024 Synty Studios Limited. All rights reserved. // // Use of this software is subject to the terms and conditions of the Synty Studios End User Licence Agreement (EULA) // available at: https://syntystore.com/pages/end-user-licence-agreement // // For additional details, see the LICENSE.MD file bundled with this software. // using SqlCipher4Unity3D; // using SqlCipher4Unity3D; using SQLite; using Synty.SidekickCharacters.Database.DTO; using System; using System.IO; using System.Linq; using UnityEngine; namespace Synty.SidekickCharacters.Database { /// /// Manages connecting to, initializing and validating a local Sidekick SQLite database /// public class DatabaseManager { private const string _DATABASE_FILE_NAME = "Side_Kick_Data.db"; private const string _LEGACY_PACKAGE_ROOT = "Assets/Synty/SidekickCharacters"; private const string _LEGACY_TOOLS_PACKAGE_ROOT = "Assets/Synty/Tools/SidekickCharacters"; private const string _WORKING_DATABASE_DIRECTORY = "SidekickCharacters"; private readonly string _CURRENT_VERSION = "1.0.2"; private static SQLiteConnection _connection; private static int _connectionHash; private static string _databasePath; private static string _seedDatabasePath; private static string _packageRootAbsolutePath; private static string _packageRootAssetPath; /// /// Gets the DB connection with the given connection details. /// If the current connection is not for the given details, the connection is replaced with the correct connection. /// /// The path to the DB. /// The connection key for the DB. /// Whether or not to valiate the structure of the DB after connecting to it. /// A connection to a DB with the given connection details. public SQLiteConnection GetDbConnection(bool checkDbOnLoad = false) { if (_connection == null) { string databasePath = GetDatabasePath(); if (string.IsNullOrEmpty(databasePath)) { throw new FileNotFoundException("Unable to locate Sidekick database file in this Unity project."); } _connection = new SQLiteConnection(databasePath, true); } else { return _connection; } if (checkDbOnLoad) { bool createTables = !IsDatabaseConfigured(); InitialiseDatabase(createTables); } return _connection; } /// /// Returns whatever the current DB connection is, regardless of state. /// /// The current DB connection. public SQLiteConnection GetCurrentDbConnection() { return _connection; } /// /// Closes the current DB connection. /// public void CloseConnection() { if (_connection == null) { return; } // NOTE: Previously, if we didn't clear the pool in the connection, it resulted in a file lock on the database, // which in some cases could cause problems when trying to update the DB to a new version. // Our SQLCipher library doesn't use pool clearing unless using SQLiteAsyncConnection. _connection.Dispose(); // Our SQLCipher library doesn't surface checking connection state; disposed connections need their reference removed _connection = null; } /// /// Initialises the Sidekicks database with required data, if they don't already exist. /// /// Whether to update the database and create the needed tables or not. private void InitialiseDatabase(bool createTables = false) { // ensure we have a default color set (a bunch of other code relies on this, not safe to remove yet) try { SidekickColorSet.GetDefault(this); } catch (Exception) { SidekickColorSet newSet = new SidekickColorSet { Species = new SidekickSpecies { ID = -1, Name = "None" }, Name = "Default", SourceColorPath = GetPackageAssetPath("Resources", "Textures", "T_ColorMap.png"), SourceMetallicPath = GetPackageAssetPath("Resources", "Textures", "T_MetallicMap.png"), SourceSmoothnessPath = GetPackageAssetPath("Resources", "Textures", "T_SmoothnessMap.png"), SourceReflectionPath = GetPackageAssetPath("Resources", "Textures", "T_ReflectionMap.png"), SourceEmissionPath = GetPackageAssetPath("Resources", "Textures", "T_EmissionMap.png"), SourceOpacityPath = GetPackageAssetPath("Resources", "Textures", "T_OpacityMap.png"), }; newSet.Save(this); } if (createTables) { GetCurrentDbConnection().CreateTable(); SidekickDBVersion version; if (GetCurrentDbConnection().Table().Any()) { version = GetCurrentDbConnection().Table().FirstOrDefault(); version.SemanticVersion = _CURRENT_VERSION; version.LastUpdated = DateTime.Now; } else { version = new SidekickDBVersion() { ID = -1, SemanticVersion = _CURRENT_VERSION, LastUpdated = DateTime.Now }; } version.Save(this); GetCurrentDbConnection().CreateTable(); GetCurrentDbConnection().CreateTable(); GetCurrentDbConnection().CreateTable(); GetCurrentDbConnection().CreateTable(); GetCurrentDbConnection().CreateTable(); GetCurrentDbConnection().CreateTable(); GetCurrentDbConnection().CreateTable(); GetCurrentDbConnection().CreateTable(); } } /// /// Checks to see if the current database has the required tables. /// TODO: Check DB version, not just if table is present. /// /// True if the tables are present; otherwise false. private bool IsDatabaseConfigured() { bool configured = !(GetCurrentDbConnection().GetTableInfo("sk_vdata").Count < 1); if (GetCurrentDbConnection().GetTableInfo("sk_part").Count < 9) { configured = false; } if (GetCurrentDbConnection().GetTableInfo("sk_part_image").Count < 1) { configured = false; } if (GetCurrentDbConnection().GetTableInfo("sk_part_filter").Count < 1) { configured = false; } if (GetCurrentDbConnection().GetTableInfo("sk_part_filter_row").Count < 1) { configured = false; } if (GetCurrentDbConnection().GetTableInfo("sk_part_preset_row").Count < 5) { configured = false; } if (GetCurrentDbConnection().GetTableInfo("sk_part_species_link").Count < 3) { configured = false; } if (GetCurrentDbConnection().GetTableInfo("sk_preset_filter").Count < 1) { configured = false; } if (GetCurrentDbConnection().GetTableInfo("sk_preset_filter_row").Count < 1) { configured = false; } return configured; } /// /// Retrieves the current database version. /// /// Semantic version (major.minor.patch). public Version GetDatabaseVersion() { return new Version(GetCurrentDbConnection()?.Table().FirstOrDefault().SemanticVersion ?? "0.0.1ea"); } /// /// Gets the absolute filesystem path to the Sidekick database file. /// /// The absolute path to the DB file, if found. public static string GetDatabasePath() { if (!string.IsNullOrEmpty(_databasePath) && File.Exists(_databasePath)) { return _databasePath; } string seedDatabasePath = GetSeedDatabasePath(); if (string.IsNullOrEmpty(seedDatabasePath)) { return null; } string projectRoot = Directory.GetParent(Application.dataPath)?.FullName; if (string.IsNullOrEmpty(projectRoot)) { return null; } string workingDirectory = Path.Combine(projectRoot, "Library", _WORKING_DATABASE_DIRECTORY); Directory.CreateDirectory(workingDirectory); _databasePath = Path.Combine(workingDirectory, _DATABASE_FILE_NAME); if (!File.Exists(_databasePath)) { File.Copy(seedDatabasePath, _databasePath, false); } _databasePath = Path.GetFullPath(_databasePath); CleanupLegacyDatabaseArtifacts(seedDatabasePath); return _databasePath; } /// /// Gets the absolute filesystem path to the Sidekick package root. /// /// The absolute path to the package root, if found. public static string GetPackageRootAbsolutePath() { if (!string.IsNullOrEmpty(_packageRootAbsolutePath) && Directory.Exists(_packageRootAbsolutePath)) { return _packageRootAbsolutePath; } string seedDatabasePath = GetSeedDatabasePath(); if (string.IsNullOrEmpty(seedDatabasePath)) { return null; } string databaseDirectory = Path.GetDirectoryName(seedDatabasePath); if (string.IsNullOrEmpty(databaseDirectory)) { return null; } _packageRootAbsolutePath = Path.GetDirectoryName(databaseDirectory); if (!string.IsNullOrEmpty(_packageRootAbsolutePath)) { _packageRootAbsolutePath = Path.GetFullPath(_packageRootAbsolutePath); } return _packageRootAbsolutePath; } /// /// Gets the project-relative asset path to the Sidekick package root. /// /// The asset path to the package root, if found. public static string GetPackageRootAssetPath() { if (!string.IsNullOrEmpty(_packageRootAssetPath)) { return _packageRootAssetPath; } string packageRootAbsolutePath = GetPackageRootAbsolutePath(); if (string.IsNullOrEmpty(packageRootAbsolutePath)) { return null; } _packageRootAssetPath = ToAssetPath(packageRootAbsolutePath); return _packageRootAssetPath; } /// /// Builds a project-relative asset path under the Sidekick package root. /// /// Path segments under the package root. /// The combined asset path, if the package root could be found. public static string GetPackageAssetPath(params string[] relativeSegments) { string packageRootAssetPath = GetPackageRootAssetPath(); if (string.IsNullOrEmpty(packageRootAssetPath)) { return null; } string combinedPath = packageRootAssetPath; foreach (string segment in relativeSegments) { combinedPath = Path.Combine(combinedPath, segment); } return NormalizePathSeparators(combinedPath); } /// /// Normalizes legacy Sidekick asset paths so moved packages still load correctly. /// /// The asset path to normalize. /// The remapped path if it pointed at an old root; otherwise the original path. public static string NormalizeLegacyAssetPath(string assetPath) { if (string.IsNullOrWhiteSpace(assetPath)) { return assetPath; } string normalizedPath = NormalizePathSeparators(assetPath); string packageRootAssetPath = GetPackageRootAssetPath(); if (string.IsNullOrEmpty(packageRootAssetPath)) { return normalizedPath; } if (normalizedPath.StartsWith(_LEGACY_TOOLS_PACKAGE_ROOT, StringComparison.OrdinalIgnoreCase)) { return packageRootAssetPath + normalizedPath.Substring(_LEGACY_TOOLS_PACKAGE_ROOT.Length); } if (normalizedPath.StartsWith(_LEGACY_PACKAGE_ROOT, StringComparison.OrdinalIgnoreCase)) { return packageRootAssetPath + normalizedPath.Substring(_LEGACY_PACKAGE_ROOT.Length); } return normalizedPath; } private static string ToAssetPath(string fullPath) { string normalizedFullPath = NormalizePathSeparators(Path.GetFullPath(fullPath)); string normalizedAssetsPath = NormalizePathSeparators(Path.GetFullPath(Application.dataPath)); if (!normalizedFullPath.StartsWith(normalizedAssetsPath, StringComparison.OrdinalIgnoreCase)) { return normalizedFullPath; } return "Assets" + normalizedFullPath.Substring(normalizedAssetsPath.Length); } private static string NormalizePathSeparators(string path) { return path?.Replace('\\', '/'); } private static string GetSeedDatabasePath() { if (!string.IsNullOrEmpty(_seedDatabasePath) && File.Exists(_seedDatabasePath)) { return _seedDatabasePath; } string[] databaseFiles = Directory.GetFiles(Application.dataPath, _DATABASE_FILE_NAME, SearchOption.AllDirectories); _seedDatabasePath = databaseFiles .OrderBy(path => path.Contains($"{Path.DirectorySeparatorChar}SidekickCharacters{Path.DirectorySeparatorChar}Database{Path.DirectorySeparatorChar}") ? 0 : 1) .FirstOrDefault(); if (!string.IsNullOrEmpty(_seedDatabasePath)) { _seedDatabasePath = Path.GetFullPath(_seedDatabasePath); } return _seedDatabasePath; } private static void CleanupLegacyDatabaseArtifacts(string seedDatabasePath) { if (string.IsNullOrEmpty(seedDatabasePath)) { return; } DeleteIfExists(seedDatabasePath + "-journal"); DeleteIfExists(seedDatabasePath + "-journal.meta"); DeleteIfExists(seedDatabasePath + "-wal"); DeleteIfExists(seedDatabasePath + "-wal.meta"); DeleteIfExists(seedDatabasePath + "-shm"); DeleteIfExists(seedDatabasePath + "-shm.meta"); } private static void DeleteIfExists(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch { } } } }