diff --git a/CMakeLists.txt b/CMakeLists.txt index 88700e9b18b..201f638f5ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -866,6 +866,7 @@ add_library(mixxx-lib STATIC EXCLUDE_FROM_ALL src/util/duration.cpp src/util/experiment.cpp src/util/file.cpp + src/util/fileinfo.cpp src/util/imageutils.cpp src/util/indexrange.cpp src/util/logger.cpp @@ -1493,6 +1494,7 @@ add_executable(mixxx-test src/test/enginemastertest.cpp src/test/enginemicrophonetest.cpp src/test/enginesynctest.cpp + src/test/fileinfo_test.cpp src/test/globaltrackcache_test.cpp src/test/hotcuecontrol_test.cpp src/test/imageutils_test.cpp diff --git a/src/mixxxapplication.cpp b/src/mixxxapplication.cpp index 289374bf4ea..6c529cf0b0e 100644 --- a/src/mixxxapplication.cpp +++ b/src/mixxxapplication.cpp @@ -13,6 +13,7 @@ #include "track/trackref.h" #include "util/cache.h" #include "util/color/rgbcolor.h" +#include "util/fileinfo.h" #include "util/math.h" // When linking Qt statically on Windows we have to Q_IMPORT_PLUGIN all the @@ -96,6 +97,7 @@ void MixxxApplication::registerMetaTypes() { qRegisterMetaType("mixxx::Bpm"); qRegisterMetaType("mixxx::Duration"); qRegisterMetaType>("std::optional"); + qRegisterMetaType("mixxx::FileInfo"); } bool MixxxApplication::notify(QObject* target, QEvent* event) { diff --git a/src/test/fileinfo_test.cpp b/src/test/fileinfo_test.cpp new file mode 100644 index 00000000000..0e579624414 --- /dev/null +++ b/src/test/fileinfo_test.cpp @@ -0,0 +1,101 @@ +#include "util/fileinfo.h" + +#include + +#include +#include + +namespace mixxx { + +class FileInfoTest : public testing::Test { + protected: + const QTemporaryDir m_tempDir; + + const QString m_relativePath; + const QString m_absolutePath; + + const QString m_relativePathMissing; + const QString m_absolutePathMissing; + + FileInfoTest() + : m_relativePath(QStringLiteral("relative")), + m_absolutePath(m_tempDir.filePath(m_relativePath)), + m_relativePathMissing(QStringLiteral("missing")), + m_absolutePathMissing(m_tempDir.filePath(m_relativePathMissing)) { + } + + void SetUp() override { + ASSERT_TRUE(m_tempDir.isValid()); + ASSERT_TRUE(QDir(m_tempDir.path()).mkpath(m_relativePath)); + ASSERT_TRUE(FileInfo(m_absolutePath).exists()); + ASSERT_FALSE(FileInfo(m_absolutePathMissing).exists()); + } +}; + +TEST_F(FileInfoTest, emptyPathIsRelative) { + EXPECT_FALSE(FileInfo().isAbsolute()); + EXPECT_TRUE(FileInfo().isRelative()); +} + +TEST_F(FileInfoTest, nonEmptyPathIsEitherAbsoluteOrRelative) { + EXPECT_TRUE(FileInfo(m_absolutePath).isAbsolute()); + EXPECT_FALSE(FileInfo(m_absolutePath).isRelative()); + EXPECT_TRUE(FileInfo(m_absolutePathMissing).isAbsolute()); + EXPECT_FALSE(FileInfo(m_absolutePathMissing).isRelative()); + EXPECT_FALSE(FileInfo(m_relativePath).isAbsolute()); + EXPECT_TRUE(FileInfo(m_relativePath).isRelative()); + EXPECT_FALSE(FileInfo(m_relativePathMissing).isAbsolute()); + EXPECT_TRUE(FileInfo(m_relativePathMissing).isRelative()); +} + +TEST_F(FileInfoTest, hasLocation) { + EXPECT_FALSE(FileInfo().hasLocation()); + EXPECT_TRUE(FileInfo(m_absolutePath).hasLocation()); + EXPECT_TRUE(FileInfo(m_absolutePath).hasLocation()); + EXPECT_FALSE(FileInfo(m_relativePath).hasLocation()); + EXPECT_TRUE(FileInfo(m_absolutePathMissing).hasLocation()); + EXPECT_FALSE(FileInfo(m_relativePathMissing).hasLocation()); +} + +TEST_F(FileInfoTest, freshCanonicalFileInfo) { + FileInfo fileInfo(m_absolutePathMissing); + // This test assumes that caching is enabled resulting + // in expected inconsistencies until refreshed. + ASSERT_TRUE(fileInfo.m_fileInfo.caching()); + + ASSERT_TRUE(fileInfo.canonicalLocation().isEmpty()); + ASSERT_TRUE(fileInfo.resolveCanonicalLocation().isEmpty()); + + // Restore the missing file + QFile file(m_absolutePathMissing); + ASSERT_FALSE(fileInfo.checkFileExists()); + ASSERT_TRUE(file.open(QIODevice::ReadWrite | QIODevice::NewOnly)); + ASSERT_TRUE(fileInfo.checkFileExists()); + + // The cached canonical location should still be invalid + EXPECT_TRUE(fileInfo.canonicalLocation().isEmpty()); + // The refreshed canonical location should be valid + EXPECT_FALSE(fileInfo.resolveCanonicalLocation().isEmpty()); + // The cached canonical location should have been updated + EXPECT_FALSE(fileInfo.canonicalLocation().isEmpty()); + + // Remove the file + ASSERT_TRUE(file.remove()); + ASSERT_FALSE(fileInfo.checkFileExists()); + ASSERT_TRUE(FileInfo(m_absolutePathMissing).canonicalLocation().isEmpty()); + + // Note: Qt (5.14.x) doesn't seem to invalidate the canonical location + // after it has been set once, even when refreshing the QFileInfo. This + // is what the remaining part of the test validates, although Mixxx does + // NOT rely on this behavior! Just to get notified when this behavior + // changes for some future Qt version and for ensuring that the behavior + // is identical on all platforms. + + // The cached canonical location should still be valid + EXPECT_FALSE(fileInfo.canonicalLocation().isEmpty()); + // The canonical location should not be refreshed again, i.e. it remains + // valid after the file has disappeared + EXPECT_FALSE(fileInfo.resolveCanonicalLocation().isEmpty()); +} + +} // namespace mixxx diff --git a/src/track/trackfile.h b/src/track/trackfile.h index e1500ed5627..170c4d6ccfc 100644 --- a/src/track/trackfile.h +++ b/src/track/trackfile.h @@ -20,6 +20,8 @@ // // Copying an instance of this class is thread-safe, because the // underlying QFileInfo is implicitly shared. +// +// TODO: Replace with mixxx::FileInfo class TrackFile { public: static TrackFile fromUrl(const QUrl& url) { diff --git a/src/util/fileinfo.cpp b/src/util/fileinfo.cpp new file mode 100644 index 00000000000..f8c69335970 --- /dev/null +++ b/src/util/fileinfo.cpp @@ -0,0 +1,40 @@ +#include "util/fileinfo.h" + +namespace mixxx { + +// static +bool FileInfo::isRootSubCanonicalLocation( + const QString& rootCanonicalLocation, + const QString& subCanonicalLocation) { + VERIFY_OR_DEBUG_ASSERT(!rootCanonicalLocation.isEmpty()) { + return false; + } + VERIFY_OR_DEBUG_ASSERT(!subCanonicalLocation.isEmpty()) { + return false; + } + DEBUG_ASSERT(QDir::isAbsolutePath(rootCanonicalLocation)); + DEBUG_ASSERT(QDir::isAbsolutePath(subCanonicalLocation)); + if (subCanonicalLocation.size() < rootCanonicalLocation.size()) { + return false; + } + if (subCanonicalLocation.size() > rootCanonicalLocation.size() && + subCanonicalLocation[rootCanonicalLocation.size()] != QChar('/')) { + return false; + } + return subCanonicalLocation.startsWith(rootCanonicalLocation); +} + +QString FileInfo::resolveCanonicalLocation() { + // Note: We return here the cached value, that was calculated just after + // init this FileInfo object. This will avoid repeated use of the time + // consuming file I/O. + QString currentCanonicalLocation = canonicalLocation(); + if (!currentCanonicalLocation.isEmpty()) { + return currentCanonicalLocation; + } + m_fileInfo.refresh(); + // Return whatever is available after the refresh + return canonicalLocation(); +} + +} // namespace mixxx diff --git a/src/util/fileinfo.h b/src/util/fileinfo.h new file mode 100644 index 00000000000..e16845d7689 --- /dev/null +++ b/src/util/fileinfo.h @@ -0,0 +1,283 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "util/assert.h" + +namespace mixxx { + +/// A thin wrapper (shim) around QFileInfo with a limited API and +/// some additional methods. +/// +/// Could be used as a drop-in replacement of QFileInfo with very +/// few exceptions where the name of member functions differ. Despite +/// the name it represents either a file, a directory, or a symbolic link. +/// +/// This class adds support for the higher-level concept of a "location" +/// that is used to reference a permanent file path. +/// +/// All single-argument are declared as explicit to prevent implicit conversions. +/// +/// Implementation note: Inheriting from QFileInfo would violate the +/// Liskov Substition Principle. It is also invalid, because QFileInfo +/// has a non-virtual destructor and we cannot override non-virtual +/// member functions. +class FileInfo final { + public: + explicit FileInfo( + QFileInfo&& fileInfo) + : m_fileInfo(std::move(fileInfo)) { + } + explicit FileInfo( + const QFileInfo& fileInfo) + : m_fileInfo(fileInfo) { + } + explicit FileInfo( + const QFile& file) + : m_fileInfo(file) { + } + explicit FileInfo( + const QString& file) + : m_fileInfo(file) { + } + explicit FileInfo( + const QDir& dir, + const QString& file = QString()) + : m_fileInfo(dir, file) { + } + FileInfo() = default; + FileInfo(FileInfo&&) = default; + FileInfo(const FileInfo&) = default; + FileInfo& operator=(FileInfo&&) = default; + FileInfo& operator=(const FileInfo&) = default; + + /// Directly access to the wrapped QFileInfo (immutable) + const QFileInfo& asQFileInfo() const { + return m_fileInfo; + } + + /// Explicit conversion from QFile. + static FileInfo fromQFile(const QFile& file) { + return FileInfo(file); + } + + /// Explicit conversion to QFile. + QFile toQFile(QObject* parent = nullptr) const { + return QFile(location(), parent); + } + + /// Explicit conversion from QDir. + static FileInfo fromQDir(const QDir& dir) { + return FileInfo(dir); + } + + /// Explicit conversion to QDir. + QDir toQDir() const { + // Due to false negatives we must assert for !isFile() instead + // of isDir()! + DEBUG_ASSERT(!isFile()); + // We cannot use QFileInfo::dir() which instead returns the + // parent directory. + return QDir(location()); + } + + /// Explicit conversion from a local file QUrl. + static FileInfo fromQUrl(const QUrl& url) { + return FileInfo(url.toLocalFile()); + } + + /// Explicit conversion to a local file QUrl. + QUrl toQUrl() const { + return QUrl::fromLocalFile(location()); + } + + /// Check that the given QFileInfo is context-insensitive to avoid + /// implicitly acccessing any transient working directory when + /// resolving relative paths. We need to exclude these unintended + /// side-effects! + static bool hasLocation(const QFileInfo& fileInfo) { + DEBUG_ASSERT(QFileInfo().isRelative()); // special case (should be excluded) + return fileInfo.isAbsolute(); + } + bool hasLocation() const { + return hasLocation(m_fileInfo); + } + + /// Returns the permanent location of a file. + static QString location(const QFileInfo& fileInfo) { + DEBUG_ASSERT(hasLocation(fileInfo)); + return fileInfo.absoluteFilePath(); + } + QString location() const { + return location(m_fileInfo); + } + + /// The directory part of the location, i.e. excluding the file name. + static QString locationPath(const QFileInfo& fileInfo) { + DEBUG_ASSERT(hasLocation(fileInfo)); + return fileInfo.absolutePath(); + } + QString locationPath() const { + return locationPath(m_fileInfo); + } + + /// Refresh the canonical location if it is still empty, i.e. if + /// the file may have re-appeared after mounting the corresponding + /// drive while Mixxx is already running. + /// + /// We ignore the case when the user changes a symbolic link to + /// point a file to an other location, since this is a user action. + /// We also don't care if a file disappears while Mixxx is running. + /// Opening a non-existent file is already handled and doesn't cause + /// any malfunction. + /// + /// Note: Refreshing will never invalidate the canonical location + /// once it has been set, even after the corresponding file has + /// been deleted! A non-empty canonical location is immutable and + /// does not disappear, other than by explicitly refresh()ing the + /// file info manually. See also: FileInfoTest + QString resolveCanonicalLocation(); + + /// Returns the current location of a physical file, i.e. + /// without aliasing by symbolic links and without any redundant + /// relative paths. + /// + /// Does only access the file system if file metadata is not + /// already cached, depending on the caching mode of QFileInfo. + static QString canonicalLocation(const QFileInfo& fileInfo) { + DEBUG_ASSERT(hasLocation(fileInfo)); + return fileInfo.canonicalFilePath(); + } + QString canonicalLocation() const { + return canonicalLocation(m_fileInfo); + } + + /// The directory part of the canonical location, i.e. excluding + /// the file name. + /// + /// Does only access the file system if file metadata is not + /// already cached, depending on the caching mode of QFileInfo. + static QString canonicalLocationPath(const QFileInfo& fileInfo) { + DEBUG_ASSERT(hasLocation(fileInfo)); + return fileInfo.canonicalPath(); + } + QString canonicalLocationPath() const { + return canonicalLocationPath(m_fileInfo); + } + + /// Decide if two canonical locations have a parent/child + /// relationship, i.e. if the sub location is contained + /// within the tree originating from the root location. + /// + /// Both canonical locations must not be empty, otherwise + /// false is returned. + static bool isRootSubCanonicalLocation( + const QString& rootCanonicalLocation, + const QString& subCanonicalLocation); + + /// Check if the file actually exists on the file system, + /// bypassing any internal caching. + bool checkFileExists() const { + // Using filePath() is faster than location() + return QFileInfo::exists(filePath()); + } + + void refresh() { + m_fileInfo.refresh(); + } + + bool exists() const { + return m_fileInfo.exists(); + } + + QString fileName() const { + return m_fileInfo.fileName(); + } + QString baseName() const { + return m_fileInfo.baseName(); + } + + QDateTime birthTime() const { + return m_fileInfo.birthTime(); + } + + QDateTime lastModified() const { + return m_fileInfo.lastModified(); + } + + // Both isFile() and isDir() might return false, but they + // will never return true at the same time, i.e. consider + // false negatives when using these functions. + bool isFile() const { + return m_fileInfo.isFile(); + } + bool isDir() const { + return m_fileInfo.isDir(); + } + + bool isReadable() const { + return m_fileInfo.isReadable(); + } + bool isWritable() const { + return m_fileInfo.isWritable(); + } + + bool isAbsolute() const { + return m_fileInfo.isAbsolute(); + } + /// Note: An empty file path is relative! + bool isRelative() const { + return m_fileInfo.isRelative(); + } + + QString suffix() const { + return m_fileInfo.suffix(); + } + QString completeSuffix() const { + return m_fileInfo.completeSuffix(); + } + + /// Query the file size in bytes. + /// + /// Note: The longer name of this method compared to QFileInfo::size() + /// has been chosen deliberately. + qint64 sizeInBytes() const { + return m_fileInfo.size(); + } + + friend bool operator==(const FileInfo& lhs, const FileInfo& rhs) { + return lhs.m_fileInfo == rhs.m_fileInfo; + } + + friend QDebug operator<<(QDebug dbg, const mixxx::FileInfo& arg) { + return dbg << arg.m_fileInfo; + } + + private: + FRIEND_TEST(FileInfoTest, freshCanonicalFileInfo); + + // The internal file path should only be used for implementation + // purpose. Using location() instead of filePath() is recommended + // for all public use cases. + QString filePath() const { + return m_fileInfo.filePath(); + } + + QFileInfo m_fileInfo; +}; + +inline bool operator!=(const FileInfo& lhs, const FileInfo& rhs) { + return !(lhs == rhs); +} + +} // namespace mixxx + +Q_DECLARE_TYPEINFO(mixxx::FileInfo, Q_MOVABLE_TYPE); // QFileInfo is movable +Q_DECLARE_METATYPE(mixxx::FileInfo)