Files
iDescriptor/src/gallerywidget.cpp
T
2025-11-09 18:37:08 -08:00

634 lines
21 KiB
C++

/*
* iDescriptor: A free and open-source idevice management tool.
*
* Copyright (C) 2025 Uncore <https://github.com/uncor3>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#include "gallerywidget.h"
#include "exportmanager.h"
#include "iDescriptor.h"
#include "mediapreviewdialog.h"
#include "photomodel.h"
#include "servicemanager.h"
#include <QComboBox>
#include <QDebug>
#include <QFileDialog>
#include <QFutureWatcher>
#include <QHBoxLayout>
#include <QItemSelectionModel>
#include <QLabel>
#include <QListView>
#include <QMenu>
#include <QMessageBox>
#include <QPushButton>
#include <QRegularExpression>
#include <QStackedWidget>
#include <QStandardItemModel>
#include <QStandardPaths>
#include <QVBoxLayout>
#include <QtConcurrent/QtConcurrent>
/*
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<int>(PhotoModel::NewestFirst));
m_sortComboBox->addItem("Oldest First",
static_cast<int>(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<int>(PhotoModel::All));
m_filterComboBox->addItem("Images Only",
static_cast<int>(PhotoModel::ImagesOnly));
m_filterComboBox->addItem("Videos Only",
static_cast<int>(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<int>::of(&QComboBox::currentIndexChanged),
this, &GalleryWidget::onSortOrderChanged);
connect(m_filterComboBox,
QOverload<int>::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<PhotoModel::SortOrder>(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<PhotoModel::FilterType>(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<ExportItem>
QList<ExportItem> 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<ExportItem>
QList<ExportItem> 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<QIcon>(this);
// Connect the finished signal to update the item icon
connect(watcher, &QFutureWatcher<QIcon>::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<QIcon> 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";
}