From 24e15f569204ec776b243607d19f230cd8523751 Mon Sep 17 00:00:00 2001 From: Lasan Mahaliyana Date: Sat, 1 Aug 2026 04:23:37 +0530 Subject: [PATCH] added tray support for qt application --- .../workflows/release-minimal-appimage.yaml | 16 +- sharing/linux/app/BUILD | 42 ++- sharing/linux/app/application_controller.cc | 337 ++++++++++++++++++ sharing/linux/app/application_controller.h | 75 ++++ .../linux/app/application_controller_test.cc | 111 ++++++ sharing/linux/app/backend.cc | 66 +++- sharing/linux/app/backend.h | 12 + sharing/linux/app/backend_test.cc | 95 ++++- sharing/linux/app/main.cc | 30 +- sharing/linux/app/main.qml | 6 + 10 files changed, 779 insertions(+), 11 deletions(-) create mode 100644 sharing/linux/app/application_controller.cc create mode 100644 sharing/linux/app/application_controller.h create mode 100644 sharing/linux/app/application_controller_test.cc diff --git a/.github/workflows/release-minimal-appimage.yaml b/.github/workflows/release-minimal-appimage.yaml index 411325cf..3a0f1c3a 100644 --- a/.github/workflows/release-minimal-appimage.yaml +++ b/.github/workflows/release-minimal-appimage.yaml @@ -72,6 +72,7 @@ jobs: libbluetooth-dev \ libxkbcommon-dev \ libxcb-cursor0 \ + dbus-daemon \ patchelf \ qt6-base-dev \ qt6-declarative-dev \ @@ -80,6 +81,14 @@ jobs: qt6-tools-dev-tools \ libqrencode-dev + - name: Test tray application lifecycle + run: | + bazel test \ + --@com_google_protobuf//bazel/toolchains:prefer_prebuilt_protoc=true \ + --copt='-DGITHUB_BUILD' \ + //sharing/linux/app:application_controller_test \ + //sharing/linux/app:backend_test + - name: Build minimal AppImage inputs run: | bazel build \ @@ -88,7 +97,9 @@ jobs: //sharing/linux/app:appimage - name: Package and validate minimal AppImage - run: ./sharing/linux/app/packaging/package_minimal_appimage.sh + env: + RUN_BACKEND_SMOKE_TEST: 1 + run: dbus-run-session -- ./sharing/linux/app/packaging/package_minimal_appimage.sh - name: Generate SHA256 checksum working-directory: sharing/linux/dist-minimal @@ -100,6 +111,9 @@ jobs: - name: Validate AppImage output run: | test -x sharing/linux/dist-minimal/QuickShare-x86_64.AppImage + test -x sharing/linux/dist-minimal/QuickShare.AppDir/AppRun + test -e sharing/linux/dist-minimal/QuickShare.AppDir/usr/lib/libQt6DBus.so.6 + test -s sharing/linux/dist-minimal/backend-smoke-test.log file sharing/linux/dist-minimal/QuickShare-x86_64.AppImage - name: Publish GitHub Release diff --git a/sharing/linux/app/BUILD b/sharing/linux/app/BUILD index 3706ee7f..c934e294 100644 --- a/sharing/linux/app/BUILD +++ b/sharing/linux/app/BUILD @@ -1,6 +1,6 @@ load("@rules_cc//cc:cc_library.bzl", "cc_library") load("@rules_cc//cc:cc_test.bzl", "cc_test") -load("@rules_qt//:qt.bzl", "qt_cc_binary", "qt_cc_library", "qt_resource_via_qrc") +load("@rules_qt//:qt.bzl", "qt_cc_binary", "qt_cc_library", "qt_cc_test", "qt_resource_via_qrc") load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("@hedron_compile_commands//:refresh_compile_commands.bzl", "refresh_compile_commands") @@ -69,6 +69,45 @@ qt_cc_library( ], ) +qt_cc_library( + name = "application_controller", + srcs = ["application_controller.cc"], + hdrs = ["application_controller.h"], + deps = [ + ":backend", + "@rules_qt//:qt_core", + "@rules_qt//:qt_dbus", + "@rules_qt//:qt_gui", + "@rules_qt//:qt_hdrs", + "@rules_qt//:qt_quick", + "@rules_qt//:qt_widgets", + ], +) + +qt_cc_test( + name = "application_controller_test", + size = "small", + srcs = ["application_controller_test.cc"], + env = {"QT_QPA_PLATFORM": "offscreen"}, + deps = [ + ":application_controller", + "@com_google_googletest//:gtest_main", + "@rules_qt//:qt_core", + "@rules_qt//:qt_gui", + "@rules_qt//:qt_hdrs", + "@rules_qt//:qt_labs_platform", + "@rules_qt//:qt_opengl", + "@rules_qt//:qt_qml", + "@rules_qt//:qt_qml_meta", + "@rules_qt//:qt_qml_workerscript", + "@rules_qt//:qt_quick", + "@rules_qt//:qt_quick_controls2", + "@rules_qt//:qt_quick_layouts", + "@rules_qt//:qt_quick_shapes", + "@rules_qt//:qt_widgets", + ], +) + cc_test( name = "backend_test", size = "small", @@ -134,6 +173,7 @@ qt_cc_binary( "QT_QPA_PLATFORM": "wayland;xcb", }, deps = [ + ":application_controller", ":app_resources", ":backend", ":native_file_logging", diff --git a/sharing/linux/app/application_controller.cc b/sharing/linux/app/application_controller.cc new file mode 100644 index 00000000..a42cb142 --- /dev/null +++ b/sharing/linux/app/application_controller.cc @@ -0,0 +1,337 @@ +#include "sharing/linux/app/application_controller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sharing/linux/app/backend.h" + +namespace { + +constexpr char kApplicationService[] = "com.google.quickshare"; +constexpr char kApplicationPath[] = "/com/google/quickshare"; +constexpr char kApplicationInterface[] = "com.google.quickshare"; +constexpr char kNotificationService[] = "org.freedesktop.Notifications"; +constexpr char kNotificationPath[] = "/org/freedesktop/Notifications"; +constexpr char kNotificationInterface[] = "org.freedesktop.Notifications"; +constexpr int kOfferTimeoutMilliseconds = 45 * 1000; +constexpr int kResultTimeoutMilliseconds = 10 * 1000; + +QString FormatBytes(qint64 bytes) { + if (bytes <= 0) { + return {}; + } + static constexpr const char* kUnits[] = {"B", "KB", "MB", "GB", "TB"}; + double value = static_cast(bytes); + int unit = 0; + while (value >= 1024.0 && unit < 4) { + value /= 1024.0; + ++unit; + } + const int precision = unit == 0 ? 0 : (value >= 10.0 ? 1 : 2); + return QStringLiteral("%1 %2").arg(QString::number(value, 'f', precision), + QString::fromLatin1(kUnits[unit])); +} + +} // namespace + +ApplicationController::ApplicationController(QApplication& application, + QObject* parent) + : QObject(parent), application_(application) { + QDBusConnection bus = QDBusConnection::sessionBus(); + if (bus.isConnected()) { + bus.connect(kNotificationService, kNotificationPath, kNotificationInterface, + QStringLiteral("ActionInvoked"), this, + SLOT(OnNotificationAction(uint, QString))); + bus.connect(kNotificationService, kNotificationPath, kNotificationInterface, + QStringLiteral("NotificationClosed"), this, + SLOT(OnNotificationClosed(uint, uint))); + + QDBusInterface notifications(kNotificationService, kNotificationPath, + kNotificationInterface, bus); + const QDBusReply capabilities = + notifications.call(QStringLiteral("GetCapabilities")); + actions_supported_ = + capabilities.isValid() && capabilities.value().contains("actions"); + } +} + +ApplicationController::~ApplicationController() { + const QList pending_offers = transfer_to_notification_.keys(); + for (qint64 share_target_id : pending_offers) { + CloseOfferNotification(share_target_id); + } + if (tray_icon_ != nullptr) { + tray_icon_->setContextMenu(nullptr); + } + QDBusConnection bus = QDBusConnection::sessionBus(); + if (owns_dbus_service_ && bus.isConnected()) { + bus.unregisterService(kApplicationService); + } + if (bus.isConnected()) { + bus.unregisterObject(kApplicationPath); + } +} + +bool ApplicationController::ClaimSingleInstance() { + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + qWarning() << "Session D-Bus is unavailable; single-instance activation " + "and actionable notifications are disabled"; + return true; + } + + if (!bus.registerObject(kApplicationPath, this, + QDBusConnection::ExportScriptableSlots)) { + qWarning() << "Unable to export QuickShare activation object:" + << bus.lastError().message(); + } + if (bus.registerService(kApplicationService)) { + owns_dbus_service_ = true; + return true; + } + + QDBusInterface existing(kApplicationService, kApplicationPath, + kApplicationInterface, bus); + const QDBusReply activation = + existing.call(QStringLiteral("ShowWindow")); + if (activation.isValid()) { + return false; + } + + qWarning() << "Unable to claim or activate the QuickShare D-Bus service:" + << activation.error().message(); + return true; +} + +void ApplicationController::InitializeTray() { + tray_menu_ = std::make_unique(); + tray_menu_->addAction(tr("Show QuickShare"), this, + &ApplicationController::ShowWindow); + tray_menu_->addSeparator(); + tray_menu_->addAction(tr("Quit"), this, &ApplicationController::Quit); + + tray_icon_ = new QSystemTrayIcon( + QIcon(QStringLiteral(":/icons/quickshare.svg")), this); + tray_icon_->setToolTip(tr("QuickShare")); + tray_icon_->setContextMenu(tray_menu_.get()); + connect(tray_icon_, &QSystemTrayIcon::activated, this, + [this](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger || + reason == QSystemTrayIcon::DoubleClick) { + ShowWindow(); + } + }); + tray_icon_->show(); + + auto* tray_availability_timer = new QTimer(this); + tray_availability_timer->setInterval(2000); + connect(tray_availability_timer, &QTimer::timeout, this, + &ApplicationController::UpdateTrayAvailability); + tray_availability_timer->start(); + UpdateTrayAvailability(); +} + +void ApplicationController::SetBackend(Backend* backend) { + if (backend == nullptr) { + return; + } + connect(backend, &Backend::incomingOffer, this, + &ApplicationController::OnIncomingOffer); + connect(backend, &Backend::incomingOfferResolved, this, + &ApplicationController::OnIncomingOfferResolved); + connect(backend, &Backend::transferFinished, this, + &ApplicationController::OnTransferFinished); + connect(this, &ApplicationController::acceptRequested, backend, + &Backend::accept); + connect(this, &ApplicationController::rejectRequested, backend, + &Backend::reject); +} + +void ApplicationController::AttachWindow(QObject* root_object) { + window_ = qobject_cast(root_object); + if (window_ != nullptr && pending_activation_) { + pending_activation_ = false; + ShowWindow(); + } +} + +void ApplicationController::ShowWindow() { + if (window_ == nullptr) { + pending_activation_ = true; + return; + } + window_->show(); + window_->raise(); + window_->requestActivate(); +} + +void ApplicationController::Quit() { + application_.quit(); +} + +void ApplicationController::OnIncomingOffer(qint64 share_target_id, + const QString& device_name, + int attachment_count, + qint64 total_bytes) { + if (!actions_supported_) { + ShowWindow(); + return; + } + + CloseOfferNotification(share_target_id); + const QStringList actions = { + QStringLiteral("default"), QStringLiteral("Open"), + QStringLiteral("accept"), QStringLiteral("Accept"), + QStringLiteral("decline"), QStringLiteral("Decline"), + }; + const uint notification_id = SendNotification( + tr("Incoming share"), + IncomingOfferBody(device_name, attachment_count, total_bytes), actions, + kOfferTimeoutMilliseconds); + if (notification_id == 0) { + ShowWindow(); + return; + } + notification_to_transfer_.insert(notification_id, share_target_id); + transfer_to_notification_.insert(share_target_id, notification_id); +} + +void ApplicationController::OnIncomingOfferResolved(qint64 share_target_id) { + CloseOfferNotification(share_target_id); +} + +void ApplicationController::OnTransferFinished(qint64 share_target_id, + bool receive_mode, + const QString& device_name, + const QString& status) { + CloseOfferNotification(share_target_id); + if (!receive_mode || + !background_accepted_transfers_.remove(share_target_id)) { + return; + } + + const bool complete = status == QStringLiteral("kComplete"); + const QString peer = + device_name.isEmpty() ? tr("the nearby device") : device_name; + SendNotification(complete ? tr("Share received") : tr("Share failed"), + complete + ? tr("Finished receiving from %1").arg(peer) + : tr("Could not receive the share from %1").arg(peer), + {}, kResultTimeoutMilliseconds); +} + +void ApplicationController::OnNotificationAction(uint notification_id, + const QString& action_key) { + const auto transfer = notification_to_transfer_.constFind(notification_id); + if (transfer == notification_to_transfer_.cend()) { + return; + } + const qint64 share_target_id = transfer.value(); + + if (action_key == QStringLiteral("default")) { + ShowWindow(); + return; + } + if (action_key == QStringLiteral("accept")) { + background_accepted_transfers_.insert(share_target_id); + emit acceptRequested(share_target_id); + } else if (action_key == QStringLiteral("decline")) { + emit rejectRequested(share_target_id); + } else { + return; + } + CloseOfferNotification(share_target_id); +} + +void ApplicationController::OnNotificationClosed(uint notification_id, + uint /*reason*/) { + RemoveNotificationMapping(notification_id); +} + +void ApplicationController::UpdateTrayAvailability() { + const bool available = QSystemTrayIcon::isSystemTrayAvailable(); + application_.setQuitOnLastWindowClosed(!available); + if (close_to_tray_ == available) { + return; + } + close_to_tray_ = available; + emit closeToTrayChanged(); +} + +uint ApplicationController::SendNotification(const QString& summary, + const QString& body, + const QStringList& actions, + int timeout_milliseconds) { + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + return 0; + } + QDBusInterface notifications(kNotificationService, kNotificationPath, + kNotificationInterface, bus); + const QDBusReply reply = + notifications.call(QStringLiteral("Notify"), QStringLiteral("QuickShare"), + uint{0}, QStringLiteral("quickshare"), summary, body, + actions, QVariantMap{}, timeout_milliseconds); + if (!reply.isValid()) { + qWarning() << "Unable to send desktop notification:" + << reply.error().message(); + return 0; + } + return reply.value(); +} + +void ApplicationController::CloseOfferNotification(qint64 share_target_id) { + const auto notification = + transfer_to_notification_.constFind(share_target_id); + if (notification == transfer_to_notification_.cend()) { + return; + } + const uint notification_id = notification.value(); + RemoveNotificationMapping(notification_id); + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + return; + } + QDBusInterface notifications(kNotificationService, kNotificationPath, + kNotificationInterface, bus); + notifications.asyncCall(QStringLiteral("CloseNotification"), notification_id); +} + +void ApplicationController::RemoveNotificationMapping(uint notification_id) { + const auto transfer = notification_to_transfer_.find(notification_id); + if (transfer == notification_to_transfer_.end()) { + return; + } + transfer_to_notification_.remove(transfer.value()); + notification_to_transfer_.erase(transfer); +} + +QString ApplicationController::IncomingOfferBody(const QString& device_name, + int attachment_count, + qint64 total_bytes) const { + const QString peer = + device_name.isEmpty() ? tr("A nearby device") : device_name; + QString contents; + if (attachment_count > 0) { + contents = attachment_count == 1 ? tr("1 item") + : tr("%1 items").arg(attachment_count); + } else { + contents = FormatBytes(total_bytes); + } + if (contents.isEmpty()) { + contents = tr("a share"); + } + return tr("%1 wants to send %2") + .arg(peer, contents); +} diff --git a/sharing/linux/app/application_controller.h b/sharing/linux/app/application_controller.h new file mode 100644 index 00000000..48c35f47 --- /dev/null +++ b/sharing/linux/app/application_controller.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +class Backend; +class QApplication; +class QMenu; +class QQuickWindow; +class QSystemTrayIcon; + +class ApplicationController : public QObject { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "com.google.quickshare") + Q_PROPERTY(bool closeToTray READ closeToTray NOTIFY closeToTrayChanged) + + public: + explicit ApplicationController(QApplication& application, + QObject* parent = nullptr); + ~ApplicationController() override; + + // Returns false after successfully activating an existing instance. + bool ClaimSingleInstance(); + void InitializeTray(); + void SetBackend(Backend* backend); + void AttachWindow(QObject* root_object); + + bool closeToTray() const { return close_to_tray_; } + + public slots: + Q_SCRIPTABLE void ShowWindow(); + void Quit(); + + signals: + void closeToTrayChanged(); + void acceptRequested(qint64 share_target_id); + void rejectRequested(qint64 share_target_id); + + private slots: + void OnIncomingOffer(qint64 share_target_id, const QString& device_name, + int attachment_count, qint64 total_bytes); + void OnIncomingOfferResolved(qint64 share_target_id); + void OnTransferFinished(qint64 share_target_id, bool receive_mode, + const QString& device_name, const QString& status); + void OnNotificationAction(uint notification_id, const QString& action_key); + void OnNotificationClosed(uint notification_id, uint reason); + void UpdateTrayAvailability(); + + private: + friend class ApplicationControllerTestPeer; + + uint SendNotification(const QString& summary, const QString& body, + const QStringList& actions, int timeout_milliseconds); + void CloseOfferNotification(qint64 share_target_id); + void RemoveNotificationMapping(uint notification_id); + QString IncomingOfferBody(const QString& device_name, int attachment_count, + qint64 total_bytes) const; + + QApplication& application_; + QPointer window_; + QSystemTrayIcon* tray_icon_ = nullptr; + std::unique_ptr tray_menu_; + QHash notification_to_transfer_; + QHash transfer_to_notification_; + QSet background_accepted_transfers_; + bool close_to_tray_ = false; + bool actions_supported_ = false; + bool owns_dbus_service_ = false; + bool pending_activation_ = false; +}; diff --git a/sharing/linux/app/application_controller_test.cc b/sharing/linux/app/application_controller_test.cc new file mode 100644 index 00000000..82d91a1b --- /dev/null +++ b/sharing/linux/app/application_controller_test.cc @@ -0,0 +1,111 @@ +#include "sharing/linux/app/application_controller.h" + +#include +#include + +#include "gtest/gtest.h" + +class ApplicationControllerTestPeer { + public: + static void AddOfferNotification(ApplicationController& controller, + uint notification_id, + qint64 share_target_id) { + controller.notification_to_transfer_.insert(notification_id, + share_target_id); + controller.transfer_to_notification_.insert(share_target_id, + notification_id); + } + + static void InvokeAction(ApplicationController& controller, + uint notification_id, const QString& action) { + controller.OnNotificationAction(notification_id, action); + } + + static void Dismiss(ApplicationController& controller, uint notification_id) { + controller.OnNotificationClosed(notification_id, /*reason=*/2); + } + + static void DeliverOfferWithoutActions(ApplicationController& controller, + qint64 share_target_id) { + controller.actions_supported_ = false; + controller.OnIncomingOffer(share_target_id, QStringLiteral("Peer"), 1, 100); + } + + static bool HasNotification(const ApplicationController& controller, + uint notification_id) { + return controller.notification_to_transfer_.contains(notification_id); + } +}; + +namespace { + +QApplication& TestApplication() { + static int argc = 1; + static char application_name[] = "application_controller_test"; + static char* argv[] = {application_name, nullptr}; + static QApplication application(argc, argv); + return application; +} + +TEST(ApplicationControllerTest, NativeActionsRouteToTransfer) { + ApplicationController controller(TestApplication()); + qint64 accepted_target = 0; + qint64 rejected_target = 0; + QObject::connect(&controller, &ApplicationController::acceptRequested, + [&accepted_target](qint64 id) { accepted_target = id; }); + QObject::connect(&controller, &ApplicationController::rejectRequested, + [&rejected_target](qint64 id) { rejected_target = id; }); + + ApplicationControllerTestPeer::AddOfferNotification(controller, 10, 101); + ApplicationControllerTestPeer::InvokeAction(controller, 10, + QStringLiteral("accept")); + ApplicationControllerTestPeer::AddOfferNotification(controller, 11, 102); + ApplicationControllerTestPeer::InvokeAction(controller, 11, + QStringLiteral("decline")); + + EXPECT_EQ(accepted_target, 101); + EXPECT_EQ(rejected_target, 102); + EXPECT_FALSE(ApplicationControllerTestPeer::HasNotification(controller, 10)); + EXPECT_FALSE(ApplicationControllerTestPeer::HasNotification(controller, 11)); +} + +TEST(ApplicationControllerTest, DismissalDoesNotResolveTransfer) { + ApplicationController controller(TestApplication()); + int responses = 0; + QObject::connect(&controller, &ApplicationController::acceptRequested, + [&responses](qint64) { ++responses; }); + QObject::connect(&controller, &ApplicationController::rejectRequested, + [&responses](qint64) { ++responses; }); + ApplicationControllerTestPeer::AddOfferNotification(controller, 20, 201); + + ApplicationControllerTestPeer::Dismiss(controller, 20); + + EXPECT_EQ(responses, 0); + EXPECT_FALSE(ApplicationControllerTestPeer::HasNotification(controller, 20)); +} + +TEST(ApplicationControllerTest, DefaultActionShowsWindow) { + ApplicationController controller(TestApplication()); + QQuickWindow window; + window.hide(); + controller.AttachWindow(&window); + ApplicationControllerTestPeer::AddOfferNotification(controller, 30, 301); + + ApplicationControllerTestPeer::InvokeAction(controller, 30, + QStringLiteral("default")); + + EXPECT_TRUE(window.isVisible()); +} + +TEST(ApplicationControllerTest, MissingNotificationActionsShowsWindow) { + ApplicationController controller(TestApplication()); + QQuickWindow window; + window.hide(); + controller.AttachWindow(&window); + + ApplicationControllerTestPeer::DeliverOfferWithoutActions(controller, 401); + + EXPECT_TRUE(window.isVisible()); +} + +} // namespace diff --git a/sharing/linux/app/backend.cc b/sharing/linux/app/backend.cc index 55b24bda..26d18921 100644 --- a/sharing/linux/app/backend.cc +++ b/sharing/linux/app/backend.cc @@ -31,6 +31,7 @@ using NearbySharingService = nearby::sharing::NearbySharingService; constexpr std::chrono::seconds kShutdownTimeout(10); constexpr int kReceiveTimeoutMilliseconds = 30 * 1000; +constexpr int kIncomingOfferTimeoutMilliseconds = 45 * 1000; QString ToQString(const std::string& value) { return QString::fromStdString(value); @@ -491,6 +492,7 @@ bool Backend::sendFile(qint64 share_target_id, const QString& path) { void Backend::accept(qint64 share_target_id) { if (service_ != nullptr && !shutting_down_) { + ResolveIncomingOffer(share_target_id); service_->Accept(share_target_id, StatusCallback(QStringLiteral("Accept transfer"))); } @@ -498,6 +500,7 @@ void Backend::accept(qint64 share_target_id) { void Backend::reject(qint64 share_target_id) { if (service_ != nullptr && !shutting_down_) { + ResolveIncomingOffer(share_target_id); service_->Reject(share_target_id, StatusCallback(QStringLiteral("Reject transfer"))); } @@ -517,6 +520,11 @@ void Backend::shutdown() { shutting_down_ = true; monitoring_requested_ = false; receive_timeout_timer_.stop(); + const QList offer_timers = incoming_offer_timers_.values(); + incoming_offer_timers_.clear(); + for (QTimer* timer : offer_timers) { + timer->stop(); + } auto& fast_init_manager = *fast_init_manager_; if (fast_init_manager.IsScanning()) { fast_init_manager.StopScanning(nullptr); @@ -559,24 +567,80 @@ void Backend::OnTransferUpdate( const nearby::sharing::AttachmentContainer& attachments, const TransferMetadata& transfer) { const int64_t total_bytes = attachments.GetTotalAttachmentsSize(); + const int attachment_count = attachments.GetAttachmentCount(); QPointer backend(this); - PostStatus(this, [backend, receive_mode, target, transfer, total_bytes]() { + PostStatus(this, [backend, receive_mode, target, transfer, total_bytes, + attachment_count]() { if (!backend || backend->shutting_down_) { return; } backend->targets_.ApplyTarget(target); backend->transfers_.ApplyTransfer(receive_mode, target, transfer, total_bytes); + if (!transfer.is_final_status()) { + backend->finished_transfer_ids_.remove(target.id); + } if (receive_mode && transfer.status() == TransferMetadata::Status::kAwaitingLocalConfirmation) { backend->receive_offer_received_ = true; backend->receive_timeout_timer_.stop(); + backend->StartIncomingOfferTimer(target.id); emit backend->incomingTransfer(target.id); + emit backend->incomingOffer(target.id, ToQString(target.device_name), + attachment_count, total_bytes); + return; + } + + if (receive_mode) { + backend->ResolveIncomingOffer(target.id); + } + if (transfer.is_final_status() && + !backend->finished_transfer_ids_.contains(target.id)) { + backend->finished_transfer_ids_.insert(target.id); + emit backend->transferFinished( + target.id, receive_mode, ToQString(target.device_name), + ToQString(TransferMetadata::StatusToString(transfer.status()))); + if (receive_mode) { + backend->StartFastInitiationMonitoring(); + } } }); } +void Backend::StartIncomingOfferTimer(qint64 share_target_id) { + ResolveIncomingOffer(share_target_id); + auto* timer = new QTimer(this); + timer->setSingleShot(true); + timer->setInterval(kIncomingOfferTimeoutMilliseconds); + connect(timer, &QTimer::timeout, this, [this, share_target_id]() { + OnIncomingOfferTimeout(share_target_id); + }); + incoming_offer_timers_.insert(share_target_id, timer); + timer->start(); +} + +void Backend::ResolveIncomingOffer(qint64 share_target_id) { + QTimer* timer = incoming_offer_timers_.take(share_target_id); + if (timer == nullptr) { + return; + } + timer->stop(); + timer->deleteLater(); + emit incomingOfferResolved(share_target_id); +} + +void Backend::OnIncomingOfferTimeout(qint64 share_target_id) { + if (!incoming_offer_timers_.contains(share_target_id)) { + return; + } + ResolveIncomingOffer(share_target_id); + if (service_ != nullptr && !shutting_down_) { + service_->Reject(share_target_id, + StatusCallback(QStringLiteral("Expire incoming offer"))); + } +} + void Backend::SetDesiredMode(Mode mode) { desired_mode_ = mode; DriveMode(); diff --git a/sharing/linux/app/backend.h b/sharing/linux/app/backend.h index d05c7fda..e21737eb 100644 --- a/sharing/linux/app/backend.h +++ b/sharing/linux/app/backend.h @@ -8,7 +8,9 @@ #include #include +#include #include +#include #include #include @@ -129,6 +131,11 @@ class Backend : public QObject, signals: void incomingTransfer(qint64 share_target_id); + void incomingOffer(qint64 share_target_id, QString device_name, + int attachment_count, qint64 total_bytes); + void incomingOfferResolved(qint64 share_target_id); + void transferFinished(qint64 share_target_id, bool receive_mode, + QString device_name, QString status); void outgoingTransferStartFailed(qint64 share_target_id); private: @@ -185,6 +192,9 @@ class Backend : public QObject, nearby::api::FastInitiationManager::Error error); void OpenReceiveWindow(bool fallback); void OnReceiveTimeout(); + void StartIncomingOfferTimer(qint64 share_target_id); + void ResolveIncomingOffer(qint64 share_target_id); + void OnIncomingOfferTimeout(qint64 share_target_id); std::function StatusCallback( QString operation); void ReportStatus(const QString& operation, @@ -199,6 +209,8 @@ class Backend : public QObject, TransferCallback send_transfer_callback_; TransferCallback receive_transfer_callback_; QTimer receive_timeout_timer_; + QHash incoming_offer_timers_; + QSet finished_transfer_ids_; Mode active_mode_ = Mode::kNone; Mode desired_mode_ = Mode::kNone; bool initialized_ = false; diff --git a/sharing/linux/app/backend_test.cc b/sharing/linux/app/backend_test.cc index f38c89c9..a22096c2 100644 --- a/sharing/linux/app/backend_test.cc +++ b/sharing/linux/app/backend_test.cc @@ -39,9 +39,18 @@ class BackendTestPeer { return backend.monitoring_requested_; } + static bool HasIncomingOfferTimer(const Backend& backend, + int64_t share_target_id) { + return backend.incoming_offer_timers_.contains(share_target_id); + } + static void ExpireReceiveWindow(Backend& backend) { backend.OnReceiveTimeout(); } + + static void ExpireIncomingOffer(Backend& backend, int64_t share_target_id) { + backend.OnIncomingOfferTimeout(share_target_id); + } }; namespace { @@ -97,10 +106,16 @@ class TestSharingService final : public nearby::sharing::NearbySharingService { std::function callback) override { callback(StatusCodes::kOk); } - void Accept(int64_t, std::function callback) override { + void Accept(int64_t share_target_id, + std::function callback) override { + ++accept_count_; + last_accepted_target_ = share_target_id; callback(StatusCodes::kOk); } - void Reject(int64_t, std::function callback) override { + void Reject(int64_t share_target_id, + std::function callback) override { + ++reject_count_; + last_rejected_target_ = share_target_id; callback(StatusCodes::kOk); } void Cancel(int64_t, std::function callback) override { @@ -148,9 +163,18 @@ class TestSharingService final : public nearby::sharing::NearbySharingService { receive_callback_->OnTransferUpdate(target, attachments, metadata); } + int accept_count() const { return accept_count_; } + int reject_count() const { return reject_count_; } + int64_t last_accepted_target() const { return last_accepted_target_; } + int64_t last_rejected_target() const { return last_rejected_target_; } + private: bool send_registered_ = false; nearby::sharing::TransferUpdateCallback* receive_callback_ = nullptr; + int accept_count_ = 0; + int reject_count_ = 0; + int64_t last_accepted_target_ = 0; + int64_t last_rejected_target_ = 0; }; class TestFastInitiationManager final @@ -286,6 +310,73 @@ TEST_F(BackendFastInitiationTest, IncomingOfferCancelsReceiveTimeout) { EXPECT_TRUE(BackendTestPeer::IsReceiving(*backend_)); EXPECT_FALSE(BackendTestPeer::IsReceiveTimerActive(*backend_)); + EXPECT_TRUE(BackendTestPeer::HasIncomingOfferTimer(*backend_, target.id)); +} + +TEST_F(BackendFastInitiationTest, AcceptCancelsIncomingOfferTimeout) { + fast_init_manager_.FireDiscovered(); + DrainEvents(); + + nearby::sharing::ShareTarget target; + target.id = 11; + auto attachments = nearby::sharing::AttachmentContainer::Builder().Build(); + service_.FireReceiveTransferUpdate( + target, *attachments, + nearby::sharing::TransferMetadataBuilder() + .set_status(nearby::sharing::TransferMetadata::Status:: + kAwaitingLocalConfirmation) + .build()); + DrainEvents(); + + backend_->accept(target.id); + + EXPECT_FALSE(BackendTestPeer::HasIncomingOfferTimer(*backend_, target.id)); + EXPECT_EQ(service_.accept_count(), 1); + EXPECT_EQ(service_.last_accepted_target(), target.id); + BackendTestPeer::ExpireIncomingOffer(*backend_, target.id); + EXPECT_EQ(service_.reject_count(), 0); +} + +TEST_F(BackendFastInitiationTest, IncomingOfferTimeoutRejectsExactlyOnce) { + fast_init_manager_.FireDiscovered(); + DrainEvents(); + + nearby::sharing::ShareTarget target; + target.id = 12; + auto attachments = nearby::sharing::AttachmentContainer::Builder().Build(); + service_.FireReceiveTransferUpdate( + target, *attachments, + nearby::sharing::TransferMetadataBuilder() + .set_status(nearby::sharing::TransferMetadata::Status:: + kAwaitingLocalConfirmation) + .build()); + DrainEvents(); + + BackendTestPeer::ExpireIncomingOffer(*backend_, target.id); + BackendTestPeer::ExpireIncomingOffer(*backend_, target.id); + + EXPECT_FALSE(BackendTestPeer::HasIncomingOfferTimer(*backend_, target.id)); + EXPECT_EQ(service_.reject_count(), 1); + EXPECT_EQ(service_.last_rejected_target(), target.id); +} + +TEST_F(BackendFastInitiationTest, FinalReceiveRestartsMonitoring) { + fast_init_manager_.FireDiscovered(); + DrainEvents(); + + nearby::sharing::ShareTarget target; + target.id = 13; + auto attachments = nearby::sharing::AttachmentContainer::Builder().Build(); + service_.FireReceiveTransferUpdate( + target, *attachments, + nearby::sharing::TransferMetadataBuilder() + .set_status(nearby::sharing::TransferMetadata::Status::kComplete) + .build()); + DrainEvents(); + + EXPECT_TRUE(BackendTestPeer::IsIdle(*backend_)); + EXPECT_TRUE(BackendTestPeer::IsMonitoringRequested(*backend_)); + EXPECT_TRUE(fast_init_manager_.IsScanning()); } TEST_F(BackendFastInitiationTest, DiscoveryStopsFastInitiationScanning) { diff --git a/sharing/linux/app/main.cc b/sharing/linux/app/main.cc index 572ffaba..0571d4e7 100644 --- a/sharing/linux/app/main.cc +++ b/sharing/linux/app/main.cc @@ -16,6 +16,7 @@ #include #include #include +#include "application_controller.h" #include "backend.h" #include "qobject.h" #include "sharing/linux/app/native_file_logging.h" @@ -92,8 +93,8 @@ void InstallTerminationCleanup(QApplication& app) { fcntl(g_signal_pipe[1], F_SETFL, fcntl(g_signal_pipe[1], F_GETFL, 0) | O_NONBLOCK); - auto* notifier = new QSocketNotifier(g_signal_pipe[0], - QSocketNotifier::Read, &app); + auto* notifier = + new QSocketNotifier(g_signal_pipe[0], QSocketNotifier::Read, &app); QObject::connect(notifier, &QSocketNotifier::activated, &app, [&app, notifier](int socket) { notifier->setEnabled(false); @@ -103,7 +104,7 @@ void InstallTerminationCleanup(QApplication& app) { app.quit(); }); - struct sigaction action {}; + struct sigaction action{}; action.sa_handler = HandleTerminationSignal; sigemptyset(&action.sa_mask); action.sa_flags = 0; @@ -118,13 +119,33 @@ int main(int argc, char* argv[]) { nearby::sharing::linux::NativeFileLogging::Initialize(); QApplication app(argc, argv); app.setApplicationName(QStringLiteral("QuickShare")); + app.setOrganizationDomain(QStringLiteral("com.google")); app.setDesktopFileName(QStringLiteral("quickshare")); app.setWindowIcon(QIcon(QStringLiteral(":/icons/quickshare.svg"))); + app.setQuitOnLastWindowClosed(false); + + ApplicationController application_controller(app); + if (!application_controller.ClaimSingleInstance()) { + return 0; + } Backend backend; + application_controller.SetBackend(&backend); + application_controller.InitializeTray(); QQmlApplicationEngine engine; InstallTerminationCleanup(app); + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("appController", + &application_controller); + QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, + &application_controller, + [&application_controller](QObject* object, const QUrl&) { + if (object != nullptr) { + application_controller.AttachWindow(object); + } + }); + const int fontId = QFontDatabase::addApplicationFont(":/googlesans_var.ttf"); if (fontId != -1) { @@ -137,8 +158,6 @@ int main(int argc, char* argv[]) { } if (IsHotReloadEnabled()) { - engine.rootContext()->setContextProperty("backend", &backend); - const QDir qml_source_dir = QmlSourceDir(); const QUrl source_url = QUrl::fromLocalFile(qml_source_dir.absoluteFilePath("main.qml")); @@ -191,7 +210,6 @@ int main(int argc, char* argv[]) { return result; } - engine.rootContext()->setContextProperty("backend", &backend); // Loaded via the qrc scheme since it's compiled into the binary const QUrl url(QStringLiteral("qrc:/main.qml")); diff --git a/sharing/linux/app/main.qml b/sharing/linux/app/main.qml index 6874b0c1..c4697112 100644 --- a/sharing/linux/app/main.qml +++ b/sharing/linux/app/main.qml @@ -9,6 +9,12 @@ Window { title: qsTr("QuickShare") color: "#DCF5FF" + onClosing: function(close) { + if (appController.closeToTray) { + close.accepted = false + root.hide() + } + } FontLoader { id: googlesans