/* * iDescriptor: A free and open-source idevice management tool. * * Copyright (C) 2025 Uncore * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ #include "gallerywidget.h" #include "exportmanager.h" #include "iDescriptor.h" #include "mediapreviewdialog.h" #include "photomodel.h" #include "servicemanager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include /* FIXME: this needs to be refactored once we figure out how to query Photos.sqlite Check out: https://github.com/ScottKjr3347/iOS_Local_PL_Photos.sqlite_Queries */ GalleryWidget::GalleryWidget(iDescriptorDevice *device, QWidget *parent) : QWidget{parent}, m_device(device), m_model(nullptr), m_stackedWidget(nullptr), m_albumSelectionWidget(nullptr), m_albumListView(nullptr), m_photoGalleryWidget(nullptr), m_listView(nullptr), m_backButton(nullptr) { } /*Load is called when the tab is active*/ void GalleryWidget::load() { if (m_loaded) return; m_loaded = true; setupUI(); } void GalleryWidget::setupUI() { m_mainLayout = new QVBoxLayout(this); m_mainLayout->setContentsMargins(0, 0, 0, 0); // Setup controls at the top (outside of stacked widget) setupControlsLayout(); // Create stacked widget for different views m_stackedWidget = new QStackedWidget(this); // Setup album selection view setupAlbumSelectionView(); // Setup photo gallery view setupPhotoGalleryView(); // Add stacked widget to main layout m_mainLayout->addWidget(m_stackedWidget); setLayout(m_mainLayout); // Start with album selection view and load albums m_stackedWidget->setCurrentWidget(m_albumSelectionWidget); setControlsEnabled(false); // Disable controls until album is selected loadAlbumList(); } void GalleryWidget::setupControlsLayout() { m_controlsLayout = new QHBoxLayout(); m_controlsLayout->setSpacing(5); m_controlsLayout->setContentsMargins(7, 7, 7, 7); // Sort order combo box QLabel *sortLabel = new QLabel("Sort:"); sortLabel->setStyleSheet("font-weight: bold;"); m_sortComboBox = new QComboBox(); m_sortComboBox->addItem("Newest First", static_cast(PhotoModel::NewestFirst)); m_sortComboBox->addItem("Oldest First", static_cast(PhotoModel::OldestFirst)); m_sortComboBox->setCurrentIndex(0); // Default to Newest First m_sortComboBox->setMinimumWidth(100); // Ensure text fits m_sortComboBox->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); // Filter combo box QLabel *filterLabel = new QLabel("Filter:"); filterLabel->setStyleSheet("font-weight: bold;"); m_filterComboBox = new QComboBox(); m_filterComboBox->addItem("All Media", static_cast(PhotoModel::All)); m_filterComboBox->addItem("Images Only", static_cast(PhotoModel::ImagesOnly)); m_filterComboBox->addItem("Videos Only", static_cast(PhotoModel::VideosOnly)); m_filterComboBox->setCurrentIndex(0); // Default to All m_filterComboBox->setMinimumWidth(100); // Ensure text fits m_filterComboBox->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); // Export buttons m_exportSelectedButton = new QPushButton("Export Selected"); m_exportSelectedButton->setEnabled(false); // Initially disabled m_exportSelectedButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); m_exportAllButton = new QPushButton("Export All"); // Back button m_backButton = new QPushButton("← Back to Albums"); m_backButton->hide(); // Hidden initially // Connect signals connect(m_sortComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &GalleryWidget::onSortOrderChanged); connect(m_filterComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &GalleryWidget::onFilterChanged); connect(m_exportSelectedButton, &QPushButton::clicked, this, &GalleryWidget::onExportSelected); connect(m_exportAllButton, &QPushButton::clicked, this, &GalleryWidget::onExportAll); connect(m_backButton, &QPushButton::clicked, this, &GalleryWidget::onBackToAlbums); // Add widgets to layout m_controlsLayout->addWidget(m_backButton); m_controlsLayout->addWidget(sortLabel); m_controlsLayout->addWidget(m_sortComboBox); m_controlsLayout->addWidget(filterLabel); m_controlsLayout->addWidget(m_filterComboBox); m_controlsLayout->addStretch(); // Push export buttons to the right m_controlsLayout->addWidget(m_exportSelectedButton); m_controlsLayout->addWidget(m_exportAllButton); QWidget *controlsWidget = new QWidget(); controlsWidget->setLayout(m_controlsLayout); controlsWidget->setObjectName("controlsWidget"); controlsWidget->setStyleSheet("QWidget#controlsWidget { " " padding: 2px; " "}"); m_mainLayout->addWidget(controlsWidget); } void GalleryWidget::onSortOrderChanged() { if (!m_model) return; int sortValue = m_sortComboBox->currentData().toInt(); PhotoModel::SortOrder order = static_cast(sortValue); m_model->setSortOrder(order); qDebug() << "Sort order changed to:" << (order == PhotoModel::NewestFirst ? "Newest First" : "Oldest First"); } PhotoModel::FilterType GalleryWidget::getCurrentFilterType() const { int filterValue = m_filterComboBox->currentData().toInt(); return static_cast(filterValue); } void GalleryWidget::onFilterChanged() { if (!m_model) return; PhotoModel::FilterType filter = getCurrentFilterType(); m_model->setFilterType(filter); QString filterName = m_filterComboBox->currentText(); qDebug() << "Filter changed to:" << filterName; } void GalleryWidget::onExportSelected() { if (!m_model || !m_listView->selectionModel()->hasSelection()) { QMessageBox::information(this, "No Selection", "Please select photos to export."); return; } if (ExportManager::sharedInstance()->isExporting()) { QMessageBox::information(this, "Export in Progress", "An export is already in progress."); return; } QModelIndexList selectedIndexes = m_listView->selectionModel()->selectedIndexes(); QStringList filePaths = m_model->getSelectedFilePaths(selectedIndexes); if (filePaths.isEmpty()) { QMessageBox::information(this, "No Items", "No valid items selected for export."); return; } QString exportDir = selectExportDirectory(); if (exportDir.isEmpty()) { return; } // Convert QStringList to QList QList exportItems; for (const QString &filePath : filePaths) { QString fileName = filePath.split('/').last(); exportItems.append(ExportItem(filePath, fileName)); } qDebug() << "Starting export of selected files:" << exportItems.size() << "items to" << exportDir; ExportManager::sharedInstance()->startExport(m_device, exportItems, exportDir); } void GalleryWidget::onExportAll() { if (!m_model) return; if (ExportManager::sharedInstance()->isExporting()) { QMessageBox::information(this, "Export in Progress", "An export is already in progress."); return; } QStringList filePaths = m_model->getFilteredFilePaths(); if (filePaths.isEmpty()) { QMessageBox::information(this, "No Items", "No items to export."); return; } QString message = QString("Export all %1 items currently shown?").arg(filePaths.size()); int reply = QMessageBox::question(this, "Export All", message, QMessageBox::Yes | QMessageBox::No, QMessageBox::No); if (reply != QMessageBox::Yes) { return; } QString exportDir = selectExportDirectory(); if (exportDir.isEmpty()) { return; } // Convert QStringList to QList QList exportItems; for (const QString &filePath : filePaths) { QString fileName = filePath.split('/').last(); exportItems.append(ExportItem(filePath, fileName)); } qDebug() << "Starting export of all filtered files:" << exportItems.size() << "items to" << exportDir; // Start export and the manager will show its own dialog ExportManager::sharedInstance()->startExport(m_device, exportItems, exportDir); } QString GalleryWidget::selectExportDirectory() { QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); QString selectedDir = QFileDialog::getExistingDirectory( this, "Select Export Directory", defaultDir, QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); return selectedDir; } void GalleryWidget::setupAlbumSelectionView() { m_albumSelectionWidget = new QWidget(); QVBoxLayout *layout = new QVBoxLayout(m_albumSelectionWidget); layout->setContentsMargins(0, 0, 0, 0); // Add instructions label QLabel *instructionLabel = new QLabel("Select a photo album:"); instructionLabel->setStyleSheet("font-weight: bold;"); layout->addWidget(instructionLabel); m_albumListView = new QListView(); m_albumListView->setViewMode(QListView::IconMode); m_albumListView->setFlow(QListView::LeftToRight); m_albumListView->setWrapping(true); m_albumListView->setResizeMode(QListView::Adjust); m_albumListView->setIconSize(QSize(120, 120)); m_albumListView->setSpacing(10); m_albumListView->setSelectionMode(QAbstractItemView::ExtendedSelection); m_albumListView->setUniformItemSizes(true); m_albumListView->setStyleSheet("QListView { " " border-top: 1px solid #c1c1c1ff; " " background-color: transparent; " " padding: 0px;" "} " "QListView::item { " " width: 150px; " " height: 150px; " " margin: 2px; " "}"); layout->addWidget(m_albumListView); m_stackedWidget->addWidget(m_albumSelectionWidget); connect(m_albumListView, &QListView::doubleClicked, this, [this](const QModelIndex &index) { if (!index.isValid()) return; QString albumPath = index.data(Qt::UserRole).toString(); onAlbumSelected(albumPath); }); } void GalleryWidget::setupPhotoGalleryView() { m_photoGalleryWidget = new QWidget(); QVBoxLayout *layout = new QVBoxLayout(m_photoGalleryWidget); layout->setContentsMargins(0, 0, 0, 0); // Create list view for photos m_listView = new QListView(); m_listView->setViewMode(QListView::IconMode); m_listView->setFlow(QListView::LeftToRight); m_listView->setWrapping(true); m_listView->setResizeMode(QListView::Adjust); m_listView->setIconSize(QSize(120, 120)); m_listView->setSpacing(10); m_listView->setSelectionMode(QAbstractItemView::ExtendedSelection); m_listView->setUniformItemSizes(true); m_listView->setContextMenuPolicy(Qt::CustomContextMenu); m_listView->setStyleSheet("QListView { " " border-top: 1px solid #c1c1c1ff; " " background-color: transparent; " " padding: 0px;" "} " "QListView::item { " " width: 150px; " " height: 150px; " " margin: 2px; " "}"); layout->addWidget(m_listView); // Add the photo gallery widget to stacked widget m_stackedWidget->addWidget(m_photoGalleryWidget); // Connect double-click to open preview dialog connect(m_listView, &QListView::doubleClicked, this, [this](const QModelIndex &index) { if (!index.isValid()) return; QString filePath = m_model->data(index, Qt::UserRole).toString(); if (filePath.isEmpty()) return; qDebug() << "Opening preview for" << filePath; auto *previewDialog = new MediaPreviewDialog( m_device, m_device->afcClient, filePath, this); previewDialog->setAttribute(Qt::WA_DeleteOnClose); previewDialog->show(); }); connect(m_listView, &QListView::customContextMenuRequested, this, &GalleryWidget::onPhotoContextMenu); } void GalleryWidget::loadAlbumList() { AFCFileTree dcimTree = ServiceManager::safeGetFileTree(m_device, "/DCIM"); if (!dcimTree.success) { qDebug() << "Failed to read DCIM directory"; QMessageBox::warning(this, "Error", "Could not access DCIM directory on device."); return; } qDebug() << "DCIM directory read successfully, found" << dcimTree.entries.size() << "entries"; auto *albumModel = new QStandardItemModel(this); for (const MediaEntry &entry : dcimTree.entries) { QString albumName = QString::fromStdString(entry.name); qDebug() << "DCIM entry:" << albumName << "(isDir:" << entry.isDir << ")"; // Check if it's a directory and matches common iOS photo album patterns if (entry.isDir && (albumName.contains("APPLE") || QRegularExpression("^\\d{3}APPLE$").match(albumName).hasMatch() || QRegularExpression("^\\d{4}\\d{2}\\d{2}$") .match(albumName) .hasMatch())) { auto *item = new QStandardItem(albumName); QString fullPath = QString("/DCIM/%1").arg(albumName); item->setData(fullPath, Qt::UserRole); // Store full path item->setIcon(QIcon::fromTheme("folder")); albumModel->appendRow(item); loadAlbumThumbnailAsync(fullPath, item); } } m_albumListView->setModel(albumModel); } void GalleryWidget::onAlbumSelected(const QString &albumPath) { m_currentAlbumPath = albumPath; // Create model if not exists if (!m_model) { m_model = new PhotoModel(m_device, getCurrentFilterType(), this); m_listView->setModel(m_model); // Update export button states based on selection connect(m_listView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [this]() { bool hasSelection = m_listView->selectionModel()->hasSelection(); m_exportSelectedButton->setEnabled(hasSelection); }); } // Set album path and load photos m_model->setAlbumPath(albumPath); // Switch to photo gallery view m_stackedWidget->setCurrentWidget(m_photoGalleryWidget); // Enable controls and show back button setControlsEnabled(true); m_backButton->show(); } void GalleryWidget::onBackToAlbums() { // Switch back to album selection view m_stackedWidget->setCurrentWidget(m_albumSelectionWidget); m_model->clear(); // Disable controls and hide back button setControlsEnabled(false); m_backButton->hide(); // Clear current album path m_currentAlbumPath.clear(); } void GalleryWidget::setControlsEnabled(bool enabled) { m_sortComboBox->setEnabled(enabled); m_filterComboBox->setEnabled(enabled); m_exportSelectedButton->setEnabled( enabled && m_listView && m_listView->selectionModel()->hasSelection()); m_exportAllButton->setEnabled(enabled); } /* FIXME: this needs to be refactored once we figure out how to query Photos.sqlite Check out: https://github.com/ScottKjr3347/iOS_Local_PL_Photos.sqlite_Queries */ QIcon GalleryWidget::loadAlbumThumbnail(const QString &albumPath) { // Get album directory contents AFCFileTree albumTree = ServiceManager::safeGetFileTree(m_device, albumPath.toStdString()); if (!albumTree.success) { qDebug() << "Failed to read album directory:" << albumPath; return QIcon(); } // Find the first image file QString firstImagePath; for (const MediaEntry &entry : albumTree.entries) { QString fileName = QString::fromStdString(entry.name); if (!entry.isDir && (fileName.endsWith(".JPG", Qt::CaseInsensitive) || fileName.endsWith(".PNG", Qt::CaseInsensitive) || fileName.endsWith(".HEIC", Qt::CaseInsensitive))) { firstImagePath = albumPath + "/" + fileName; break; } } if (firstImagePath.isEmpty()) { qDebug() << "No images found in album:" << albumPath; return QIcon(); } // Load the thumbnail using ServiceManager QByteArray imageData = ServiceManager::safeReadAfcFileToByteArray( m_device, firstImagePath.toUtf8().constData()); if (imageData.isEmpty()) { qDebug() << "Could not read image data for thumbnail:" << firstImagePath; return QIcon(); } QPixmap thumbnail; if (firstImagePath.endsWith(".HEIC", Qt::CaseInsensitive)) { qDebug() << "Loading HEIC thumbnail from:" << firstImagePath; thumbnail = load_heic(imageData); } else { // Load regular image formats if (!thumbnail.loadFromData(imageData)) { qDebug() << "Could not decode image data for thumbnail:" << firstImagePath; return QIcon(); } } if (thumbnail.isNull()) { qDebug() << "Failed to load thumbnail from:" << firstImagePath; return QIcon(); } return QIcon(thumbnail); } void GalleryWidget::loadAlbumThumbnailAsync(const QString &albumPath, QStandardItem *item) { // Create a future watcher to handle the async result auto *watcher = new QFutureWatcher(this); // Connect the finished signal to update the item icon connect(watcher, &QFutureWatcher::finished, this, [watcher, item]() { QIcon result = watcher->result(); if (!result.isNull()) { item->setIcon(result); } // The item keeps the folder icon if thumbnail loading fails watcher->deleteLater(); }); // Start the async operation QFuture future = QtConcurrent::run( [this, albumPath]() { return loadAlbumThumbnail(albumPath); }); watcher->setFuture(future); } void GalleryWidget::onPhotoContextMenu(const QPoint &pos) { QModelIndex index = m_listView->indexAt(pos); if (!index.isValid()) { return; } // Make sure the item is selected if (!m_listView->selectionModel()->isSelected(index)) { m_listView->selectionModel()->select( index, QItemSelectionModel::ClearAndSelect); } QMenu contextMenu(this); QAction *previewAction = contextMenu.addAction("Preview"); contextMenu.addSeparator(); QAction *exportAction = contextMenu.addAction("Export"); exportAction->setEnabled(m_listView->selectionModel()->hasSelection()); connect(previewAction, &QAction::triggered, this, [this, index]() { // Re-use the double-click logic if (!index.isValid()) return; QString filePath = m_model->data(index, Qt::UserRole).toString(); if (filePath.isEmpty()) return; qDebug() << "Opening preview for" << filePath; auto *previewDialog = new MediaPreviewDialog( m_device, m_device->afcClient, filePath, this); previewDialog->setAttribute(Qt::WA_DeleteOnClose); previewDialog->show(); }); connect(exportAction, &QAction::triggered, this, &GalleryWidget::onExportSelected); contextMenu.exec(m_listView->viewport()->mapToGlobal(pos)); } GalleryWidget::~GalleryWidget() { qDebug() << "GalleryWidget destructor called"; }