From 1c1ab1d457b7fb2e42ded110d4be9bbf0c804af7 Mon Sep 17 00:00:00 2001 From: Lasan Mahaliyana Date: Sun, 22 Feb 2026 09:00:59 +0530 Subject: [PATCH] added file share qml tray app. made improvements to qml tray controller --- .../install_nearby_connections_service.sh | 159 +++ sharing/linux/nearby_connections_qt_facade.cc | 18 +- sharing/linux/nearby_connections_qt_facade.h | 90 +- .../linux/nearby_connections_service_linux.cc | 17 + sharing/linux/qml_tray_app/CMakeLists.txt | 166 ++- sharing/linux/qml_tray_app/FileShareTray.qml | 441 +++++++ sharing/linux/qml_tray_app/Main.qml | 9 +- sharing/linux/qml_tray_app/README.md | 39 +- .../file_share_tray_controller.cc | 1100 +++++++++++++++++ .../qml_tray_app/file_share_tray_controller.h | 160 +++ .../qml_tray_app/file_share_tray_main.cpp | 169 +++ sharing/linux/qml_tray_app/main.cpp | 82 +- .../qml_tray_app/nearby_tray_controller.cc | 4 +- .../qml_tray_app/nearby_tray_controller.h | 33 +- .../qml_tray_app/resources_file_share.qrc | 9 + .../linux/qml_tray_app/tray_icon-symbolic.svg | 14 + sharing/linux/qml_tray_app/tray_icon.png | Bin 0 -> 2092 bytes 17 files changed, 2303 insertions(+), 207 deletions(-) create mode 100755 sharing/linux/install_nearby_connections_service.sh create mode 100644 sharing/linux/qml_tray_app/FileShareTray.qml create mode 100644 sharing/linux/qml_tray_app/file_share_tray_controller.cc create mode 100644 sharing/linux/qml_tray_app/file_share_tray_controller.h create mode 100644 sharing/linux/qml_tray_app/file_share_tray_main.cpp create mode 100644 sharing/linux/qml_tray_app/resources_file_share.qrc create mode 100644 sharing/linux/qml_tray_app/tray_icon-symbolic.svg create mode 100644 sharing/linux/qml_tray_app/tray_icon.png diff --git a/sharing/linux/install_nearby_connections_service.sh b/sharing/linux/install_nearby_connections_service.sh new file mode 100755 index 00000000..b7c72dbc --- /dev/null +++ b/sharing/linux/install_nearby_connections_service.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET="//sharing/linux:nearby_connections_service_linux_shared" +HEADER_SRC="sharing/linux/nearby_connections_qt_facade.h" +BAZEL_CMD="${BAZEL:-bazel}" +PREFIX="/usr/local" +LIBDIR="" +INCLUDEDIR="" +SKIP_BUILD=0 +NEEDS_ELEVATION=0 +INSTALL_PREFIX=() + +usage() { + cat </lib) + --includedir DIR Include root directory (default: /include) + --bazel CMD Bazel command (default: bazel or env BAZEL) + --skip-build Skip bazel build step and only install from bazel-bin + -h, --help Show this help + +Examples: + $0 + sudo $0 + sudo $0 --prefix /usr + sudo $0 --bazel /usr/bin/bazel +USAGE +} + +run_bazel() { + # If invoked through sudo, run Bazel as the original user so Bazelisk reuses + # that user's cache and does not re-download Bazel as root. + if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then + local caller_home + caller_home="$(getent passwd "$SUDO_USER" | cut -d: -f6)" + if [[ -z "$caller_home" ]]; then + echo "Failed to resolve home directory for SUDO_USER=$SUDO_USER" >&2 + exit 1 + fi + sudo -u "$SUDO_USER" -H env \ + HOME="$caller_home" \ + BAZELISK_HOME="${BAZELISK_HOME:-$caller_home/.cache/bazelisk}" \ + "$BAZEL_CMD" "$@" + else + "$BAZEL_CMD" "$@" + fi +} + +nearest_existing_parent() { + local p="$1" + while [[ ! -e "$p" ]]; do + p="$(dirname "$p")" + done + printf '%s\n' "$p" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) + PREFIX="$2" + shift 2 + ;; + --libdir) + LIBDIR="$2" + shift 2 + ;; + --includedir) + INCLUDEDIR="$2" + shift 2 + ;; + --bazel) + BAZEL_CMD="$2" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$LIBDIR" ]]; then + LIBDIR="${PREFIX}/lib" +fi + +if [[ -z "$INCLUDEDIR" ]]; then + INCLUDEDIR="${PREFIX}/include" +fi + +LIB_PARENT="$(nearest_existing_parent "$LIBDIR")" +INCLUDE_PARENT="$(nearest_existing_parent "${INCLUDEDIR}/sharing/linux")" + +if [[ ! -w "$LIB_PARENT" || ! -w "$INCLUDE_PARENT" ]]; then + NEEDS_ELEVATION=1 +fi + +if [[ "$NEEDS_ELEVATION" -eq 1 && "$(id -u)" -ne 0 ]]; then + if ! command -v sudo >/dev/null 2>&1; then + echo "Install requires elevated privileges, but sudo is not available." >&2 + exit 1 + fi + INSTALL_PREFIX=(sudo) +fi + +if [[ ! -f "$HEADER_SRC" ]]; then + echo "Header not found: $HEADER_SRC" >&2 + echo "Run this script from the workspace root." >&2 + exit 1 +fi + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + echo "[1/4] Building $TARGET" + run_bazel build "$TARGET" +else + echo "[1/4] Skipping build (--skip-build)" +fi + +echo "[2/4] Resolving bazel-bin path" +BAZEL_BIN="$(run_bazel info bazel-bin)" +LIB_SRC="${BAZEL_BIN}/sharing/linux/libnearby_connections_service_linux_shared.so" + +if [[ ! -f "$LIB_SRC" ]]; then + echo "Shared library not found: $LIB_SRC" >&2 + echo "Expected Bazel output for $TARGET is missing." >&2 + exit 1 +fi + +echo "[3/4] Installing library and header" +"${INSTALL_PREFIX[@]}" install -d "$LIBDIR" +"${INSTALL_PREFIX[@]}" install -d "${INCLUDEDIR}/sharing/linux" +"${INSTALL_PREFIX[@]}" install -m 0755 "$LIB_SRC" "$LIBDIR/" +"${INSTALL_PREFIX[@]}" install -m 0644 "$HEADER_SRC" "${INCLUDEDIR}/sharing/linux/" + +if command -v ldconfig >/dev/null 2>&1; then + echo "[4/4] Refreshing dynamic linker cache" + "${INSTALL_PREFIX[@]}" ldconfig +else + echo "[4/4] ldconfig not found; skipping linker cache refresh" +fi + +echo "Installed:" +echo " library: $LIBDIR/$(basename "$LIB_SRC")" +echo " header : ${INCLUDEDIR}/sharing/linux/$(basename "$HEADER_SRC")" diff --git a/sharing/linux/nearby_connections_qt_facade.cc b/sharing/linux/nearby_connections_qt_facade.cc index d75ccc3a..7833b8fa 100644 --- a/sharing/linux/nearby_connections_qt_facade.cc +++ b/sharing/linux/nearby_connections_qt_facade.cc @@ -3,11 +3,13 @@ #include #include +#include "connections/implementation/flags/nearby_connections_feature_flags.h" #include "internal/base/file_path.h" +#include "internal/flags/nearby_flags.h" #include "sharing/linux/nearby_connections_service_linux.h" #include "sharing/nearby_connections_types.h" -namespace nearby::sharing::linux { +namespace nearby::sharing { namespace { @@ -345,7 +347,7 @@ std::unique_ptr ToNativePayload(Facade::Payload payloa class NearbyConnectionsQtFacade::Impl { public: - NearbyConnectionsServiceLinux service; + linux::NearbyConnectionsServiceLinux service; }; NearbyConnectionsQtFacade::NearbyConnectionsQtFacade() @@ -359,6 +361,18 @@ NearbyConnectionsQtFacade::NearbyConnectionsQtFacade( NearbyConnectionsQtFacade& NearbyConnectionsQtFacade::operator=( NearbyConnectionsQtFacade&&) noexcept = default; +void NearbyConnectionsQtFacade::SetBleL2capFlagOverrides( + bool enable_ble_l2cap, bool refactor_ble_l2cap) { + nearby::NearbyFlags::GetInstance().OverrideBoolFlagValue( + nearby::connections::config_package_nearby::nearby_connections_feature:: + kEnableBleL2cap, + enable_ble_l2cap); + nearby::NearbyFlags::GetInstance().OverrideBoolFlagValue( + nearby::connections::config_package_nearby::nearby_connections_feature:: + kRefactorBleL2cap, + refactor_ble_l2cap); +} + NearbyConnectionsQtFacade::Payload NearbyConnectionsQtFacade::CreateBytesPayload( std::vector bytes) const { Payload payload; diff --git a/sharing/linux/nearby_connections_qt_facade.h b/sharing/linux/nearby_connections_qt_facade.h index 1cf21833..a9791785 100644 --- a/sharing/linux/nearby_connections_qt_facade.h +++ b/sharing/linux/nearby_connections_qt_facade.h @@ -19,10 +19,10 @@ #undef linux #endif -namespace nearby::sharing::linux { - +namespace nearby { +namespace sharing { class NearbyConnectionsQtFacade { - public: +public: enum class Status { kSuccess = 0, kError = 1, @@ -141,84 +141,88 @@ class NearbyConnectionsQtFacade { }; struct ConnectionListener { - std::function initiated_cb; - std::function accepted_cb; - std::function rejected_cb; - std::function disconnected_cb; - std::function bandwidth_changed_cb; + std::function + initiated_cb; + std::function accepted_cb; + std::function rejected_cb; + std::function disconnected_cb; + std::function bandwidth_changed_cb; }; struct DiscoveryListener { - std::function + std::function endpoint_found_cb; - std::function endpoint_lost_cb; - std::function + std::function endpoint_lost_cb; + std::function endpoint_distance_changed_cb; }; struct PayloadListener { - std::function payload_cb; - std::function + std::function payload_cb; + std::function payload_progress_cb; }; NearbyConnectionsQtFacade(); ~NearbyConnectionsQtFacade(); - NearbyConnectionsQtFacade(const NearbyConnectionsQtFacade&) = delete; - NearbyConnectionsQtFacade& operator=(const NearbyConnectionsQtFacade&) = - delete; - NearbyConnectionsQtFacade(NearbyConnectionsQtFacade&&) noexcept; - NearbyConnectionsQtFacade& operator=( - NearbyConnectionsQtFacade&&) noexcept; + NearbyConnectionsQtFacade(const NearbyConnectionsQtFacade &) = delete; + NearbyConnectionsQtFacade & + operator=(const NearbyConnectionsQtFacade &) = delete; + NearbyConnectionsQtFacade(NearbyConnectionsQtFacade &&) noexcept; + NearbyConnectionsQtFacade &operator=(NearbyConnectionsQtFacade &&) noexcept; + + // Sets global Nearby flag overrides for BLE L2CAP. + static void SetBleL2capFlagOverrides(bool enable_ble_l2cap, + bool refactor_ble_l2cap); Payload CreateBytesPayload(std::vector bytes) const; - void StartAdvertising(const std::string& service_id, - const std::vector& endpoint_info, - const AdvertisingOptions& advertising_options, + void StartAdvertising(const std::string &service_id, + const std::vector &endpoint_info, + const AdvertisingOptions &advertising_options, ConnectionListener advertising_listener, std::function callback); - void StopAdvertising(const std::string& service_id, + void StopAdvertising(const std::string &service_id, std::function callback); - void StartDiscovery(const std::string& service_id, - const DiscoveryOptions& discovery_options, + void StartDiscovery(const std::string &service_id, + const DiscoveryOptions &discovery_options, DiscoveryListener discovery_listener, std::function callback); - void StopDiscovery(const std::string& service_id, + void StopDiscovery(const std::string &service_id, std::function callback); - void RequestConnection(const std::string& service_id, - const std::vector& endpoint_info, - const std::string& endpoint_id, - const ConnectionOptions& connection_options, + void RequestConnection(const std::string &service_id, + const std::vector &endpoint_info, + const std::string &endpoint_id, + const ConnectionOptions &connection_options, ConnectionListener connection_listener, std::function callback); - void DisconnectFromEndpoint(const std::string& service_id, - const std::string& endpoint_id, + void DisconnectFromEndpoint(const std::string &service_id, + const std::string &endpoint_id, std::function callback); - void SendPayload(const std::string& service_id, - const std::vector& endpoint_ids, Payload payload, - std::function callback); - void InitiateBandwidthUpgrade(const std::string& service_id, - const std::string& endpoint_id, + void SendPayload(const std::string &service_id, + const std::vector &endpoint_ids, + Payload payload, std::function callback); + void InitiateBandwidthUpgrade(const std::string &service_id, + const std::string &endpoint_id, std::function callback); - void AcceptConnection(const std::string& service_id, - const std::string& endpoint_id, + void AcceptConnection(const std::string &service_id, + const std::string &endpoint_id, PayloadListener payload_listener, std::function callback); void StopAllEndpoints(std::function callback); - private: +private: class Impl; std::unique_ptr impl_; }; +} // namespace sharing +} // namespace nearby -} // namespace nearby::sharing::linux - -#endif // SHARING_LINUX_NEARBY_CONNECTIONS_QT_FACADE_H_ +#endif // SHARING_LINUX_NEARBY_CONNECTIONS_QT_FACADE_H_ diff --git a/sharing/linux/nearby_connections_service_linux.cc b/sharing/linux/nearby_connections_service_linux.cc index dbb29946..acffedfa 100644 --- a/sharing/linux/nearby_connections_service_linux.cc +++ b/sharing/linux/nearby_connections_service_linux.cc @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -127,6 +128,17 @@ NcStrategy ConvertStrategy(Strategy strategy) { return NcStrategy::kP2pPointToPoint; } +void SetUpgradeMediumEnv(const nearby::sharing::MediumSelection& mediums) { + setenv("NEARBY_ALLOW_UPGRADE_BLUETOOTH", mediums.bluetooth ? "1" : "0", 1); + setenv("NEARBY_ALLOW_UPGRADE_BLE", mediums.ble ? "1" : "0", 1); + setenv("NEARBY_ALLOW_UPGRADE_WEB_RTC", mediums.web_rtc ? "1" : "0", 1); + setenv("NEARBY_ALLOW_UPGRADE_WIFI_LAN", mediums.wifi_lan ? "1" : "0", 1); + setenv("NEARBY_ALLOW_UPGRADE_WIFI_HOTSPOT", + mediums.wifi_hotspot ? "1" : "0", 1); + setenv("NEARBY_ALLOW_UPGRADE_WIFI_DIRECT", "0", 1); + setenv("NEARBY_ALLOW_UPGRADE_AWDL", "0", 1); +} + ConnectionInfo ConvertConnectionInfo(const ConnectionResponseInfo& info) { ConnectionInfo connection_info; connection_info.authentication_token = info.authentication_token; @@ -158,9 +170,11 @@ void NearbyConnectionsServiceLinux::StartAdvertising( ConnectionListener advertising_listener, std::function callback) { advertising_listener_ = std::move(advertising_listener); + SetUpgradeMediumEnv(advertising_options.allowed_mediums); NcAdvertisingOptions options{}; options.strategy = ConvertStrategy(advertising_options.strategy); + options.allowed.SetAll(false); options.allowed.ble = advertising_options.allowed_mediums.ble; options.allowed.bluetooth = advertising_options.allowed_mediums.bluetooth; options.allowed.web_rtc = advertising_options.allowed_mediums.web_rtc; @@ -217,6 +231,7 @@ void NearbyConnectionsServiceLinux::StartDiscovery( DiscoveryListener discovery_listener, std::function callback) { discovery_listener_ = std::move(discovery_listener); + SetUpgradeMediumEnv(discovery_options.allowed_mediums); NcDiscoveryOptions options{}; options.strategy = ConvertStrategy(discovery_options.strategy); @@ -274,8 +289,10 @@ void NearbyConnectionsServiceLinux::RequestConnection( std::function callback) { static_cast(service_id); connection_listener_ = std::move(connection_listener); + SetUpgradeMediumEnv(connection_options.allowed_mediums); NcConnectionOptions options{}; + options.allowed.SetAll(false); options.allowed.ble = connection_options.allowed_mediums.ble; options.allowed.bluetooth = connection_options.allowed_mediums.bluetooth; options.allowed.web_rtc = connection_options.allowed_mediums.web_rtc; diff --git a/sharing/linux/qml_tray_app/CMakeLists.txt b/sharing/linux/qml_tray_app/CMakeLists.txt index 6d99fd49..b3b03cb0 100644 --- a/sharing/linux/qml_tray_app/CMakeLists.txt +++ b/sharing/linux/qml_tray_app/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.21) project(nearby_qml_tray_app LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) @@ -11,100 +11,43 @@ set(CMAKE_AUTOUIC OFF) find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Qml Quick QuickControls2) include(GNUInstallDirs) -set(REPO_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../..") -get_filename_component(REPO_ROOT "${REPO_ROOT}" ABSOLUTE) -set(NEARBY_INCLUDE_ROOT "${CMAKE_CURRENT_BINARY_DIR}/nearby_include_root") -file(MAKE_DIRECTORY "${NEARBY_INCLUDE_ROOT}") +set(NEARBY_INSTALL_PREFIX "/usr/local" CACHE PATH "Prefix where Nearby facade header and shared library are installed") +set(NEARBY_INCLUDE_ROOT "${NEARBY_INSTALL_PREFIX}/include" CACHE PATH "Nearby include root") +set(NEARBY_LIBRARY_DIR "${NEARBY_INSTALL_PREFIX}/lib" CACHE PATH "Nearby shared library directory") -# Avoid exposing the full repo root as an include directory (which can make IDEs -# index unrelated trees like the Bazel 'external' symlink). We create a narrow -# include root that only links directories needed by headers used in this app. -set(NEARBY_INCLUDE_DIRS - sharing -) -foreach(dir_name IN LISTS NEARBY_INCLUDE_DIRS) - if(EXISTS "${REPO_ROOT}/${dir_name}") - execute_process( - COMMAND "${CMAKE_COMMAND}" -E create_symlink - "${REPO_ROOT}/${dir_name}" - "${NEARBY_INCLUDE_ROOT}/${dir_name}" - RESULT_VARIABLE LINK_RESULT - ERROR_VARIABLE LINK_ERROR - ) - if(NOT LINK_RESULT EQUAL 0) - message(FATAL_ERROR "Failed to create include symlink for ${dir_name}: ${LINK_ERROR}") - endif() - endif() -endforeach() - -set(BAZEL_EXECUTABLE "bazel" CACHE STRING "Path to bazel executable") -set( - BAZEL_NEARBY_TARGET - "//sharing/linux:nearby_connections_service_linux_shared" - CACHE STRING - "Bazel target that produces a shared library for nearby_connections_service_linux" -) -set(BAZEL_BUILD_OPTIONS - -s - --check_visibility=false - --spawn_strategy=standalone - --verbose_failures - --cxxopt=-std=c++20 - --host_cxxopt=-std=c++20 -) -string(JOIN " " BAZEL_BUILD_OPTIONS_STRING ${BAZEL_BUILD_OPTIONS}) - -execute_process( - COMMAND "${BAZEL_EXECUTABLE}" info bazel-bin - WORKING_DIRECTORY "${REPO_ROOT}" - RESULT_VARIABLE BAZEL_INFO_RESULT - OUTPUT_VARIABLE BAZEL_BIN_DIR - ERROR_VARIABLE BAZEL_INFO_ERROR - OUTPUT_STRIP_TRAILING_WHITESPACE +find_path( + NEARBY_FACADE_INCLUDE_ROOT + NAMES sharing/linux/nearby_connections_qt_facade.h + HINTS "${NEARBY_INCLUDE_ROOT}" ) -if(NOT BAZEL_INFO_RESULT EQUAL 0) - message(FATAL_ERROR "Failed to query bazel-bin with '${BAZEL_EXECUTABLE} info bazel-bin': ${BAZEL_INFO_ERROR}") +find_library( + NEARBY_CONNECTIONS_SHARED_LIB + NAMES nearby_connections_service_linux_shared + HINTS "${NEARBY_LIBRARY_DIR}" +) + +if(NOT NEARBY_FACADE_INCLUDE_ROOT) + message(FATAL_ERROR + "Could not find sharing/linux/nearby_connections_qt_facade.h. " + "Install it first (for example with sharing/linux/install_nearby_connections_service.sh) " + "or set -DNEARBY_INCLUDE_ROOT=/include.") endif() -set(BAZEL_NEARBY_SO "${BAZEL_BIN_DIR}/sharing/linux/libnearby_connections_service_linux_shared.so") -set( - BAZEL_BUILD_IF_MISSING_SCRIPT - "${CMAKE_CURRENT_LIST_DIR}/cmake/BuildNearbySoIfMissing.cmake" -) -set(BAZEL_REBUILD_INPUTS - "${REPO_ROOT}/sharing/linux/BUILD" - "${REPO_ROOT}/sharing/linux/nearby_connections_qt_facade.h" - "${REPO_ROOT}/sharing/linux/nearby_connections_qt_facade.cc" - "${REPO_ROOT}/sharing/linux/nearby_connections_service_linux.h" - "${REPO_ROOT}/sharing/linux/nearby_connections_service_linux.cc" - "${REPO_ROOT}/internal/platform/implementation/linux/crypto.cc" - "${REPO_ROOT}/internal/platform/uuid.cc" -) +if(NOT NEARBY_CONNECTIONS_SHARED_LIB) + message(FATAL_ERROR + "Could not find libnearby_connections_service_linux_shared.so. " + "Install it first (for example with sharing/linux/install_nearby_connections_service.sh) " + "or set -DNEARBY_LIBRARY_DIR=/lib.") +endif() -add_custom_target( - bazel_nearby_connections_service_linux - COMMAND "${CMAKE_COMMAND}" - -DOUTPUT_SO=${BAZEL_NEARBY_SO} - -DBAZEL_EXECUTABLE=${BAZEL_EXECUTABLE} - -DBAZEL_TARGET=${BAZEL_NEARBY_TARGET} - -DBAZEL_BUILD_OPTIONS=${BAZEL_BUILD_OPTIONS_STRING} - -DREPO_ROOT=${REPO_ROOT} - -DREBUILD_INPUTS=${BAZEL_REBUILD_INPUTS} - -P "${BAZEL_BUILD_IF_MISSING_SCRIPT}" - BYPRODUCTS "${BAZEL_NEARBY_SO}" - COMMENT "Checking/building ${BAZEL_NEARBY_TARGET} with Bazel" - VERBATIM -) - -add_library(nearby_connections_service_linux_bazel SHARED IMPORTED GLOBAL) +add_library(nearby_connections_service_linux_installed SHARED IMPORTED GLOBAL) set_target_properties( - nearby_connections_service_linux_bazel + nearby_connections_service_linux_installed PROPERTIES - IMPORTED_LOCATION "${BAZEL_NEARBY_SO}" - INTERFACE_INCLUDE_DIRECTORIES "${NEARBY_INCLUDE_ROOT}" + IMPORTED_LOCATION "${NEARBY_CONNECTIONS_SHARED_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${NEARBY_FACADE_INCLUDE_ROOT}" ) -add_dependencies(nearby_connections_service_linux_bazel bazel_nearby_connections_service_linux) qt_add_executable(nearby_qml_tray_app main.cpp @@ -113,8 +56,16 @@ qt_add_executable(nearby_qml_tray_app resources.qrc ) +qt_add_executable(nearby_qml_file_tray_app + file_share_tray_main.cpp + file_share_tray_controller.cc + file_share_tray_controller.h + resources_file_share.qrc +) + target_include_directories(nearby_qml_tray_app PRIVATE - "${NEARBY_INCLUDE_ROOT}" + "${CMAKE_CURRENT_LIST_DIR}" + "${NEARBY_FACADE_INCLUDE_ROOT}" ) target_link_libraries(nearby_qml_tray_app PRIVATE Qt6::Core @@ -123,20 +74,42 @@ target_link_libraries(nearby_qml_tray_app PRIVATE Qt6::Qml Qt6::Quick Qt6::QuickControls2 - nearby_connections_service_linux_bazel + nearby_connections_service_linux_installed ) -add_dependencies(nearby_qml_tray_app bazel_nearby_connections_service_linux) + +target_include_directories(nearby_qml_file_tray_app PRIVATE + "${CMAKE_CURRENT_LIST_DIR}" + "${NEARBY_FACADE_INCLUDE_ROOT}" +) +target_link_libraries(nearby_qml_file_tray_app PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + nearby_connections_service_linux_installed +) + +get_filename_component(NEARBY_CONNECTIONS_SHARED_LIB_DIR "${NEARBY_CONNECTIONS_SHARED_LIB}" DIRECTORY) set_target_properties(nearby_qml_tray_app PROPERTIES - BUILD_RPATH "${BAZEL_BIN_DIR}/sharing/linux" + BUILD_RPATH "${NEARBY_CONNECTIONS_SHARED_LIB_DIR}" + INSTALL_RPATH "$ORIGIN" +) +set_target_properties(nearby_qml_file_tray_app PROPERTIES + BUILD_RPATH "${NEARBY_CONNECTIONS_SHARED_LIB_DIR}" INSTALL_RPATH "$ORIGIN" ) install(TARGETS nearby_qml_tray_app RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" ) +install(TARGETS nearby_qml_file_tray_app + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" +) -install(FILES "${BAZEL_NEARBY_SO}" +install(FILES "${NEARBY_CONNECTIONS_SHARED_LIB}" DESTINATION "${CMAKE_INSTALL_BINDIR}" ) @@ -146,6 +119,17 @@ qt_generate_deploy_qml_app_script( ) install(SCRIPT "${nearby_qml_tray_app_deploy_script}") +qt_generate_deploy_qml_app_script( + TARGET nearby_qml_file_tray_app + OUTPUT_SCRIPT nearby_qml_file_tray_app_deploy_script +) +install(SCRIPT "${nearby_qml_file_tray_app_deploy_script}") + +# Convenience build target for the file tray app. +add_custom_target(build_file_tray_app + DEPENDS nearby_qml_file_tray_app +) + set(CPACK_GENERATOR "ZIP") set(CPACK_PACKAGE_NAME "nearby_qml_tray_app") set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") diff --git a/sharing/linux/qml_tray_app/FileShareTray.qml b/sharing/linux/qml_tray_app/FileShareTray.qml new file mode 100644 index 00000000..ace4d105 --- /dev/null +++ b/sharing/linux/qml_tray_app/FileShareTray.qml @@ -0,0 +1,441 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: root + width: 980 + height: 760 + minimumWidth: 820 + minimumHeight: 620 + visible: true + title: "Nearby File Tray" + + readonly property color appBg: "#f5f6f8" + readonly property color surface: "#ffffff" + readonly property color border: "#d7dbe0" + readonly property color textPrimary: "#1f2328" + readonly property color textMuted: "#59636e" + + palette.window: appBg + palette.base: surface + palette.button: "#f0f3f7" + palette.text: textPrimary + palette.windowText: textPrimary + palette.buttonText: textPrimary + palette.placeholderText: textMuted + palette.highlight: "#2f6feb" + palette.highlightedText: "#ffffff" + + background: Rectangle { + color: root.appBg + } + + function endpointLabel(endpointId) { + var label = fileShareController.peerNameForEndpoint(endpointId) + if (!label || label.length === 0 || label === "Unknown device") { + return "Unknown device" + } + return label + } + + function formatBytes(bytes) { + if (bytes === undefined || bytes === null) { + return "0 B" + } + var value = Number(bytes) + if (!isFinite(value) || value < 0) { + return "0 B" + } + var units = ["B", "KB", "MB", "GB", "TB"] + var unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + return (value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)) + " " + units[unit] + } + + onClosing: function(close) { + close.accepted = false + root.hide() + fileShareController.hideToTray() + } + + header: ToolBar { + height: 52 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + + Label { + text: "Nearby File Share" + font.bold: true + font.pixelSize: 18 + color: root.textPrimary + } + + Rectangle { + radius: 12 + color: fileShareController.mode === "Send" ? "#dbeafe" : "#dcfce7" + border.color: fileShareController.mode === "Send" ? "#60a5fa" : "#4ade80" + Layout.preferredHeight: 24 + Layout.preferredWidth: 86 + + Label { + anchors.centerIn: parent + text: fileShareController.mode + font.bold: true + color: "#1f2328" + } + } + + Item { Layout.fillWidth: true } + + Label { + text: fileShareController.running ? "Running" : "Stopped" + color: fileShareController.running ? "#1f7a1f" : "#a33" + font.bold: true + } + + Button { + text: fileShareController.running ? "Stop" : "Start" + onClicked: { + if (fileShareController.running) { + fileShareController.stop() + } else { + fileShareController.start() + } + } + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Frame { + Layout.fillWidth: true + padding: 10 + background: Rectangle { + color: root.surface + border.color: root.border + radius: 6 + } + + GridLayout { + anchors.fill: parent + columns: 4 + columnSpacing: 10 + rowSpacing: 8 + + Label { text: "Device name" } + TextField { + Layout.fillWidth: true + text: fileShareController.deviceName + onEditingFinished: fileShareController.deviceName = text + } + + Label { text: "Selected file" } + Label { + Layout.fillWidth: true + text: fileShareController.pendingSendFileName.length > 0 + ? fileShareController.pendingSendFileName + : "None" + elide: Text.ElideRight + color: root.textMuted + } + + Label { text: "Status" } + Label { + Layout.columnSpan: 3 + Layout.fillWidth: true + text: fileShareController.statusMessage + elide: Text.ElideRight + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + Frame { + Layout.preferredWidth: 420 + Layout.fillHeight: true + padding: 10 + background: Rectangle { + color: root.surface + border.color: root.border + radius: 6 + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + RowLayout { + Layout.fillWidth: true + + Label { + text: "Nearby Devices" + font.bold: true + } + + Item { Layout.fillWidth: true } + + Rectangle { + radius: 10 + color: fileShareController.mode === "Send" ? "#dbeafe" : "#f1f5f9" + border.color: fileShareController.mode === "Send" ? "#60a5fa" : "#cbd5e1" + Layout.preferredHeight: 22 + Layout.preferredWidth: 74 + + Label { + anchors.centerIn: parent + text: fileShareController.mode === "Send" ? "Discovering" : "Paused" + font.pixelSize: 11 + } + } + } + + Label { + Layout.fillWidth: true + text: fileShareController.mode === "Send" + ? "Select a nearby device to send your selected file." + : "Use tray menu Send to choose a file and start discovery." + wrapMode: Text.WordWrap + color: root.textMuted + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 6 + model: fileShareController.discoveredDevices + + delegate: Rectangle { + required property string modelData + width: ListView.view.width + height: 70 + color: root.surface + border.color: root.border + radius: 6 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + Layout.fillWidth: true + text: root.endpointLabel(modelData) + font.bold: true + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + text: modelData + color: root.textMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + Button { + text: "Send" + enabled: fileShareController.mode === "Send" + && fileShareController.pendingSendFilePath.length > 0 + onClicked: fileShareController.sendPendingFileToEndpoint(modelData) + } + } + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + Frame { + Layout.fillWidth: true + Layout.preferredHeight: 220 + padding: 10 + background: Rectangle { + color: root.surface + border.color: root.border + radius: 6 + } + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + Label { + text: "Connected Devices" + font.bold: true + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 6 + model: fileShareController.connectedDevices + + delegate: Rectangle { + required property string modelData + width: ListView.view.width + height: 46 + color: root.surface + border.color: root.border + radius: 6 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + + Label { + Layout.fillWidth: true + text: root.endpointLabel(modelData) + font.bold: true + elide: Text.ElideRight + } + + Rectangle { + radius: 10 + color: "#e2e8f0" + border.color: "#cbd5e1" + Layout.preferredHeight: 22 + Layout.preferredWidth: Math.max(68, mediumLabel.implicitWidth + 16) + + Label { + id: mediumLabel + anchors.centerIn: parent + text: fileShareController.mediumForEndpoint(modelData) + font.pixelSize: 11 + color: "#334155" + } + } + } + } + } + } + } + + Frame { + Layout.fillWidth: true + Layout.fillHeight: true + padding: 10 + background: Rectangle { + color: root.surface + border.color: root.border + radius: 6 + } + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + RowLayout { + Layout.fillWidth: true + Label { + text: "Transfers" + font.bold: true + } + Item { Layout.fillWidth: true } + Button { + text: "Clear" + onClicked: fileShareController.clearTransfers() + } + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 6 + model: fileShareController.transfers + + delegate: Rectangle { + required property var modelData + width: ListView.view.width + height: 86 + color: root.surface + border.color: root.border + radius: 6 + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + + Label { + Layout.fillWidth: true + text: modelData.direction + " | " + root.endpointLabel(modelData.endpointId) + font.bold: true + elide: Text.ElideRight + } + + Label { text: modelData.status } + + Rectangle { + radius: 10 + color: "#e2e8f0" + border.color: "#cbd5e1" + Layout.preferredHeight: 22 + Layout.preferredWidth: Math.max(68, transferMediumLabel.implicitWidth + 16) + + Label { + id: transferMediumLabel + anchors.centerIn: parent + text: modelData.medium + font.pixelSize: 11 + color: "#334155" + } + } + } + + ProgressBar { + Layout.fillWidth: true + from: 0 + to: 1 + value: modelData.progress + } + + Label { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: root.formatBytes(modelData.bytesTransferred) + + " / " + root.formatBytes(modelData.totalBytes) + color: root.textMuted + } + } + } + } + } + } + } + } + } +} diff --git a/sharing/linux/qml_tray_app/Main.qml b/sharing/linux/qml_tray_app/Main.qml index b5910846..059da41f 100644 --- a/sharing/linux/qml_tray_app/Main.qml +++ b/sharing/linux/qml_tray_app/Main.qml @@ -243,7 +243,14 @@ ApplicationWindow { id: strategyCombo Layout.preferredWidth: 170 model: ["P2pCluster", "P2pStar", "P2pPointToPoint"] - currentIndex: Math.max(0, find(nearbyController.connectionStrategy)) + currentIndex: { + var strategy = String(nearbyController.connectionStrategy) + if (strategy === "P2pStar") + return 1 + if (strategy === "P2pPointToPoint") + return 2 + return 0 + } onActivated: nearbyController.connectionStrategy = currentText } diff --git a/sharing/linux/qml_tray_app/README.md b/sharing/linux/qml_tray_app/README.md index 883c593d..26275c3d 100644 --- a/sharing/linux/qml_tray_app/README.md +++ b/sharing/linux/qml_tray_app/README.md @@ -32,45 +32,24 @@ This folder contains a Qt/QML tray application backend and UI wired to: - Transfers are shown with endpoint, direction, status, progress, and medium. - Logs are appended to `/tmp/nearby_qml_tray.log`. -## Building (host stays clean) +## Building -This app now has a Bazel target: +This CMake app links against the installed Nearby shared library and header: -- `//sharing/linux/qml_tray_app:nearby_qml_tray_app` +- `libnearby_connections_service_linux_shared.so` +- `sharing/linux/nearby_connections_qt_facade.h` -Qt compiler and linker flags are resolved through `pkg-config` at build time. - -### Recommended: build in container - -From repo root: +Install them first (repo root): ```bash -./sharing/linux/qml_tray_app/build_in_container.sh +./sharing/linux/install_nearby_connections_service.sh ``` -This command: - -- builds an image from `sharing/linux/qml_tray_app/container/Dockerfile` -- installs Bazel + Qt 6 dev packages inside that image -- runs the Bazel build in the container -- keeps Bazel cache in a Docker volume (`nearby_qml_tray_bazel_cache`) - -Useful overrides: +Then build the app (from `sharing/linux/qml_tray_app`): ```bash -# Use podman instead of docker. -CONTAINER_RUNTIME=podman ./sharing/linux/qml_tray_app/build_in_container.sh - -# Pin a custom image tag. -QML_TRAY_IMAGE_TAG=nearby-qml-tray-builder:v1 ./sharing/linux/qml_tray_app/build_in_container.sh -``` - -### Build in an already-provisioned environment - -If you already have Bazel + Qt 6 dev dependencies installed: - -```bash -./sharing/linux/qml_tray_app/build.sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DNEARBY_INSTALL_PREFIX=/usr/local +cmake --build build -j ``` ## Bundle `libnearby_connections_service_linux_shared.so` with the app diff --git a/sharing/linux/qml_tray_app/file_share_tray_controller.cc b/sharing/linux/qml_tray_app/file_share_tray_controller.cc new file mode 100644 index 00000000..d3c06dcc --- /dev/null +++ b/sharing/linux/qml_tray_app/file_share_tray_controller.cc @@ -0,0 +1,1100 @@ +#include "file_share_tray_controller.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +using NearbyConnectionsQtFacade = nearby::sharing::NearbyConnectionsQtFacade; + +std::atomic g_local_payload_id{1'000'000}; + +bool IsTerminalPayloadStatus(NearbyConnectionsQtFacade::PayloadStatus status) { + return status == NearbyConnectionsQtFacade::PayloadStatus::kSuccess || + status == NearbyConnectionsQtFacade::PayloadStatus::kFailure || + status == NearbyConnectionsQtFacade::PayloadStatus::kCanceled; +} + +} // namespace + +FileShareTrayController::FileShareTrayController(QObject* parent) + : QObject(parent) { + const QString host = QSysInfo::machineHostName(); + if (!host.isEmpty()) { + device_name_ = host; + } + + LoadSettings(); + ReopenLogFile(); + LogLine(QStringLiteral("Started file share tray controller")); +} + +FileShareTrayController::~FileShareTrayController() { + stop(); + if (log_file_.isOpen()) { + log_file_.close(); + } +} + +void FileShareTrayController::LoadSettings() { + QSettings settings(QStringLiteral("Nearby"), QStringLiteral("QmlFileTrayApp")); + + const QString stored_device_name = + settings.value(QStringLiteral("deviceName"), device_name_) + .toString() + .trimmed(); + if (!stored_device_name.isEmpty()) { + device_name_ = stored_device_name; + } +} + +void FileShareTrayController::SaveSettings() const { + QSettings settings(QStringLiteral("Nearby"), QStringLiteral("QmlFileTrayApp")); + settings.setValue(QStringLiteral("deviceName"), device_name_); + settings.sync(); +} + +void FileShareTrayController::setDeviceName(const QString& device_name) { + const QString trimmed = device_name.trimmed(); + if (trimmed.isEmpty() || trimmed == device_name_) { + return; + } + + device_name_ = trimmed; + emit deviceNameChanged(); + SaveSettings(); + LogLine(QStringLiteral("Device name changed to %1").arg(device_name_)); + + if (running_) { + stop(); + start(); + } +} + +void FileShareTrayController::start() { + if (running_) { + return; + } + + running_ = true; + emit runningChanged(); + + if (mode_ == QStringLiteral("Send")) { + startSendMode(); + } else { + startReceiveMode(); + } +} + +void FileShareTrayController::stop() { + if (!running_) { + return; + } + + running_ = false; + emit runningChanged(); + + service_.StopDiscovery( + service_id_, [this](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, status]() { + LogLine(QStringLiteral("StopDiscovery: %1") + .arg(StatusToString(status))); + }, + Qt::QueuedConnection); + }); + + service_.StopAdvertising( + service_id_, [this](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, status]() { + LogLine(QStringLiteral("StopAdvertising: %1") + .arg(StatusToString(status))); + }, + Qt::QueuedConnection); + }); + + service_.StopAllEndpoints([this](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, status]() { + LogLine(QStringLiteral("StopAllEndpoints: %1") + .arg(StatusToString(status))); + }, + Qt::QueuedConnection); + }); + + discovered_devices_.clear(); + connected_devices_.clear(); + endpoint_peer_names_.clear(); + endpoint_mediums_.clear(); + target_endpoint_for_send_.clear(); + + emit discoveredDevicesChanged(); + emit connectedDevicesChanged(); + emit endpointMediumsChanged(); + + SetStatus(QStringLiteral("Stopped")); +} + +void FileShareTrayController::switchToReceiveMode() { + if (running_ && HasActiveTransfers()) { + SetStatus(QStringLiteral("Cannot switch mode while transfer is active")); + emit requestTrayMessage( + QStringLiteral("Transfer in progress"), + QStringLiteral("Wait for the current transfer to complete.")); + return; + } + + if (mode_ != QStringLiteral("Receive")) { + mode_ = QStringLiteral("Receive"); + emit modeChanged(); + LogLine(QStringLiteral("Mode changed to Receive")); + if (running_) { + stop(); + start(); + return; + } + } + + if (!running_) { + start(); + } +} + +void FileShareTrayController::switchToSendModeWithFile(const QString& file_path) { + const QString trimmed_path = file_path.trimmed(); + QFileInfo info(trimmed_path); + if (trimmed_path.isEmpty() || !info.exists() || !info.isFile()) { + SetStatus(QStringLiteral("Selected file is not valid")); + emit requestTrayMessage(QStringLiteral("Send canceled"), + QStringLiteral("Please choose a valid file.")); + return; + } + + pending_send_file_path_ = info.absoluteFilePath(); + pending_send_file_name_ = info.fileName(); + emit pendingSendFilePathChanged(); + emit pendingSendFileNameChanged(); + + if (running_ && HasActiveTransfers()) { + SetStatus(QStringLiteral("Cannot switch mode while transfer is active")); + emit requestTrayMessage( + QStringLiteral("Transfer in progress"), + QStringLiteral("Wait for the current transfer to complete.")); + return; + } + + if (mode_ != QStringLiteral("Send")) { + mode_ = QStringLiteral("Send"); + emit modeChanged(); + LogLine(QStringLiteral("Mode changed to Send")); + if (running_) { + stop(); + start(); + } + } + + if (!running_) { + start(); + } + + SetStatus(QStringLiteral("Discovery started. Choose a nearby device.")); + emit requestTrayMessage( + QStringLiteral("Send mode"), + QStringLiteral("Selected %1. Choose a nearby device to send.") + .arg(pending_send_file_name_)); +} + +void FileShareTrayController::sendPendingFileToEndpoint(const QString& endpoint_id) { + const QString endpoint = endpoint_id.trimmed(); + if (endpoint.isEmpty()) { + return; + } + + if (pending_send_file_path_.isEmpty()) { + SetStatus(QStringLiteral("No file selected")); + emit requestTrayMessage(QStringLiteral("Send failed"), + QStringLiteral("Select a file first.")); + return; + } + + target_endpoint_for_send_ = endpoint; + + if (connected_devices_.contains(endpoint)) { + sendPendingFile(endpoint); + return; + } + + requestConnectionForSend(endpoint); +} + +QString FileShareTrayController::mediumForEndpoint(const QString& endpoint_id) const { + return endpoint_mediums_.value(endpoint_id).toString(); +} + +QString FileShareTrayController::peerNameForEndpoint(const QString& endpoint_id) const { + return PeerLabelForEndpoint(endpoint_id); +} + +void FileShareTrayController::clearTransfers() { + transfers_.clear(); + transfer_row_for_payload_.clear(); + outgoing_file_payload_to_endpoint_.clear(); + outgoing_file_payload_to_name_.clear(); + send_terminal_notified_.clear(); + emit transfersChanged(); +} + +void FileShareTrayController::hideToTray() { + emit requestTrayMessage( + QStringLiteral("Nearby File Tray"), + QStringLiteral("App is still running in the system tray.")); +} + +void FileShareTrayController::startSendMode() { + discovered_devices_.clear(); + emit discoveredDevicesChanged(); + + service_.StartDiscovery( + service_id_, BuildDiscoveryOptions(), BuildDiscoveryListener(), + [this](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, status]() { + SetStatus(QStringLiteral("StartDiscovery: %1") + .arg(StatusToString(status))); + LogLine(QStringLiteral("StartDiscovery: %1") + .arg(StatusToString(status))); + if (status != NearbyConnectionsQtFacade::Status::kSuccess) { + running_ = false; + emit runningChanged(); + } + }, + Qt::QueuedConnection); + }); +} + +void FileShareTrayController::startReceiveMode() { + service_.StartAdvertising( + service_id_, BuildEndpointInfo(), BuildAdvertisingOptions(), + BuildConnectionListener(), + [this](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, status]() { + SetStatus(QStringLiteral("StartAdvertising: %1") + .arg(StatusToString(status))); + LogLine(QStringLiteral("StartAdvertising: %1") + .arg(StatusToString(status))); + if (status != NearbyConnectionsQtFacade::Status::kSuccess) { + running_ = false; + emit runningChanged(); + } + }, + Qt::QueuedConnection); + }); +} + +std::vector FileShareTrayController::BuildEndpointInfo() const { + QByteArray endpoint = device_name_.toUtf8(); + return std::vector(endpoint.begin(), endpoint.end()); +} + +NearbyConnectionsQtFacade::ConnectionListener +FileShareTrayController::BuildConnectionListener() { + NearbyConnectionsQtFacade::ConnectionListener listener; + + listener.initiated_cb = [this](const std::string& endpoint_id, + const NearbyConnectionsQtFacade::ConnectionInfo& info) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id), + peer_name = QString::fromStdString(info.peer_name), + incoming = info.is_incoming_connection]() { + SetPeerNameForEndpoint(endpoint, peer_name); + const QString peer = PeerLabelForEndpoint(endpoint); + + if (incoming) { + SetStatus(QStringLiteral("Incoming connection from %1").arg(peer)); + } + + // Always auto-accept connections for this app. + acceptIncomingInternal(endpoint); + }, + Qt::QueuedConnection); + }; + + listener.accepted_cb = [this](const std::string& endpoint_id) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id)]() { + const QString peer = PeerLabelForEndpoint(endpoint); + AddConnectedDevice(endpoint); + SetStatus(QStringLiteral("Connected to %1").arg(peer)); + LogLine(QStringLiteral("Connection accepted endpoint=%1 peer=%2") + .arg(endpoint, peer)); + + if (!target_endpoint_for_send_.isEmpty() && + target_endpoint_for_send_ == endpoint && + !pending_send_file_path_.isEmpty()) { + sendPendingFile(endpoint); + } + }, + Qt::QueuedConnection); + }; + + listener.rejected_cb = + [this](const std::string& endpoint_id, NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id), status]() { + const QString peer = PeerLabelForEndpoint(endpoint); + SetStatus(QStringLiteral("Connection rejected by %1 (%2)") + .arg(peer, StatusToString(status))); + LogLine(QStringLiteral("Connection rejected endpoint=%1 status=%2") + .arg(endpoint, StatusToString(status))); + }, + Qt::QueuedConnection); + }; + + listener.disconnected_cb = [this](const std::string& endpoint_id) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id)]() { + const QString peer = PeerLabelForEndpoint(endpoint); + RemoveConnectedDevice(endpoint); + endpoint_mediums_.remove(endpoint); + emit endpointMediumsChanged(); + SetStatus(QStringLiteral("Disconnected from %1").arg(peer)); + LogLine(QStringLiteral("Disconnected endpoint=%1").arg(endpoint)); + }, + Qt::QueuedConnection); + }; + + listener.bandwidth_changed_cb = + [this](const std::string& endpoint_id, NearbyConnectionsQtFacade::Medium medium) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id), medium]() { + const QString medium_name = MediumToString(medium); + endpoint_mediums_[endpoint] = medium_name; + emit endpointMediumsChanged(); + UpdateTransferMediumForEndpoint(endpoint, medium_name); + LogLine(QStringLiteral("Bandwidth changed endpoint=%1 medium=%2") + .arg(endpoint, medium_name)); + }, + Qt::QueuedConnection); + }; + + return listener; +} + +NearbyConnectionsQtFacade::DiscoveryListener +FileShareTrayController::BuildDiscoveryListener() { + NearbyConnectionsQtFacade::DiscoveryListener listener; + + listener.endpoint_found_cb = + [this](const std::string& endpoint_id, + const NearbyConnectionsQtFacade::DiscoveredEndpointInfo& info) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id), + peer_name = QString::fromStdString(info.peer_name)]() { + SetPeerNameForEndpoint(endpoint, peer_name); + AddDiscoveredDevice(endpoint); + LogLine(QStringLiteral("Discovered endpoint=%1 peer=%2") + .arg(endpoint, PeerLabelForEndpoint(endpoint))); + }, + Qt::QueuedConnection); + }; + + listener.endpoint_lost_cb = [this](const std::string& endpoint_id) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id)]() { + RemoveDiscoveredDevice(endpoint); + LogLine(QStringLiteral("Lost endpoint=%1").arg(endpoint)); + }, + Qt::QueuedConnection); + }; + + listener.endpoint_distance_changed_cb = + [this](const std::string& endpoint_id, NearbyConnectionsQtFacade::DistanceInfo info) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id), info]() { + LogLine(QStringLiteral("Distance changed endpoint=%1 value=%2") + .arg(endpoint) + .arg(static_cast(info))); + }, + Qt::QueuedConnection); + }; + + return listener; +} + +NearbyConnectionsQtFacade::PayloadListener +FileShareTrayController::BuildPayloadListener() { + NearbyConnectionsQtFacade::PayloadListener listener; + + listener.payload_cb = + [this](const std::string& endpoint_id, NearbyConnectionsQtFacade::Payload payload) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id), + payload = std::move(payload)]() { + if (payload.type == NearbyConnectionsQtFacade::Payload::Type::kBytes) { + const QString text = QString::fromUtf8( + reinterpret_cast(payload.bytes.data()), + static_cast(payload.bytes.size())); + if (text.startsWith(QStringLiteral("FILE:"))) { + const QString filename = text.mid(5).trimmed(); + if (!filename.isEmpty()) { + pending_file_names_[endpoint] = filename; + } + } + return; + } + + if (payload.type != NearbyConnectionsQtFacade::Payload::Type::kFile) { + return; + } + + QString file_name = QString::fromStdString(payload.file_name).trimmed(); + if (pending_file_names_.contains(endpoint)) { + file_name = pending_file_names_.take(endpoint); + } + incoming_file_endpoints_[payload.id] = endpoint; + incoming_file_names_[payload.id] = file_name; + incoming_file_paths_[payload.id] = + QString::fromStdString(payload.file_path); + + LogLine(QStringLiteral("Incoming file payload announced endpoint=%1 id=%2 name=%3 path=%4") + .arg(endpoint) + .arg(payload.id) + .arg(file_name, QString::fromStdString(payload.file_path))); + }, + Qt::QueuedConnection); + }; + + listener.payload_progress_cb = + [this](const std::string& endpoint_id, + const NearbyConnectionsQtFacade::PayloadTransferUpdate& update) { + QMetaObject::invokeMethod( + this, + [this, endpoint = QString::fromStdString(endpoint_id), update]() { + const bool is_outgoing_file = + outgoing_file_payload_to_endpoint_.contains(update.payload_id); + const QString direction = + is_outgoing_file ? QStringLiteral("outgoing") : QStringLiteral("incoming"); + + UpsertTransfer(endpoint, update.payload_id, + PayloadStatusToString(update.status), + update.bytes_transferred, update.total_bytes, + direction); + + if (!is_outgoing_file || !IsTerminalPayloadStatus(update.status) || + send_terminal_notified_.contains(update.payload_id)) { + if (!is_outgoing_file && + update.status == NearbyConnectionsQtFacade::PayloadStatus::kSuccess && + incoming_file_paths_.contains(update.payload_id)) { + const QString received_path = incoming_file_paths_.take(update.payload_id); + const QString received_name = incoming_file_names_.take(update.payload_id); + const QString incoming_endpoint = + incoming_file_endpoints_.take(update.payload_id); + const QString final_path = FinalizeReceivedFilePath( + received_path, received_name, update.payload_id); + const QString peer = PeerLabelForEndpoint(incoming_endpoint); + const QString final_name = QFileInfo(final_path).fileName(); + emit requestTrayMessage( + QStringLiteral("File received"), + QStringLiteral("%1 from %2").arg(final_name, peer)); + LogLine(QStringLiteral("Received file endpoint=%1 id=%2 saved=%3") + .arg(incoming_endpoint) + .arg(update.payload_id) + .arg(final_path)); + } else if (!is_outgoing_file && + IsTerminalPayloadStatus(update.status)) { + incoming_file_paths_.remove(update.payload_id); + incoming_file_names_.remove(update.payload_id); + incoming_file_endpoints_.remove(update.payload_id); + } + return; + } + + send_terminal_notified_.insert(update.payload_id); + + const QString peer = PeerLabelForEndpoint(endpoint); + const QString file_name = + outgoing_file_payload_to_name_.value(update.payload_id, + QStringLiteral("file")); + + if (update.status == NearbyConnectionsQtFacade::PayloadStatus::kSuccess) { + emit requestTrayMessage( + QStringLiteral("Send complete"), + QStringLiteral("%1 sent to %2").arg(file_name, peer)); + } else { + emit requestTrayMessage( + QStringLiteral("Send failed"), + QStringLiteral("%1 failed to send to %2") + .arg(file_name, peer)); + } + + outgoing_file_payload_to_endpoint_.remove(update.payload_id); + outgoing_file_payload_to_name_.remove(update.payload_id); + send_terminal_notified_.remove(update.payload_id); + + if (!pending_send_file_path_.isEmpty()) { + pending_send_file_path_.clear(); + emit pendingSendFilePathChanged(); + } + if (!pending_send_file_name_.isEmpty()) { + pending_send_file_name_.clear(); + emit pendingSendFileNameChanged(); + } + target_endpoint_for_send_.clear(); + + disconnectDevice(endpoint); + }, + Qt::QueuedConnection); + }; + + return listener; +} + +NearbyConnectionsQtFacade::MediumSelection +FileShareTrayController::BuildMediumSelection() const { + NearbyConnectionsQtFacade::MediumSelection selection; + selection.bluetooth = true; + selection.ble = true; + selection.wifi_lan = true; + selection.wifi_hotspot = true; + selection.web_rtc = false; + return selection; +} + +NearbyConnectionsQtFacade::AdvertisingOptions +FileShareTrayController::BuildAdvertisingOptions() const { + NearbyConnectionsQtFacade::AdvertisingOptions options; + options.strategy = NearbyConnectionsQtFacade::Strategy::kP2pPointToPoint; + options.allowed_mediums = BuildMediumSelection(); + options.auto_upgrade_bandwidth = true; + options.enable_bluetooth_listening = true; + options.enforce_topology_constraints = true; + return options; +} + +NearbyConnectionsQtFacade::DiscoveryOptions +FileShareTrayController::BuildDiscoveryOptions() const { + NearbyConnectionsQtFacade::DiscoveryOptions options; + options.strategy = NearbyConnectionsQtFacade::Strategy::kP2pPointToPoint; + options.allowed_mediums = BuildMediumSelection(); + return options; +} + +NearbyConnectionsQtFacade::ConnectionOptions +FileShareTrayController::BuildConnectionOptions() const { + NearbyConnectionsQtFacade::ConnectionOptions options; + options.allowed_mediums = BuildMediumSelection(); + options.non_disruptive_hotspot_mode = true; + return options; +} + +void FileShareTrayController::acceptIncomingInternal(const QString& endpoint_id) { + const QString endpoint = endpoint_id.trimmed(); + if (endpoint.isEmpty()) { + return; + } + + service_.AcceptConnection( + service_id_, endpoint.toStdString(), BuildPayloadListener(), + [this, endpoint](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, endpoint, status]() { + const QString peer = PeerLabelForEndpoint(endpoint); + SetStatus(QStringLiteral("AcceptConnection(%1): %2") + .arg(peer, StatusToString(status))); + LogLine(QStringLiteral("AcceptConnection(%1): %2") + .arg(endpoint, StatusToString(status))); + }, + Qt::QueuedConnection); + }); +} + +void FileShareTrayController::requestConnectionForSend(const QString& endpoint_id) { + const QString endpoint = endpoint_id.trimmed(); + if (endpoint.isEmpty()) { + return; + } + + const QString peer = PeerLabelForEndpoint(endpoint); + SetStatus(QStringLiteral("Requesting connection to %1").arg(peer)); + LogLine(QStringLiteral("RequestConnection %1").arg(endpoint)); + + service_.RequestConnection( + service_id_, BuildEndpointInfo(), endpoint.toStdString(), + BuildConnectionOptions(), BuildConnectionListener(), + [this, endpoint](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, endpoint, status]() { + const QString peer = PeerLabelForEndpoint(endpoint); + SetStatus(QStringLiteral("RequestConnection(%1): %2") + .arg(peer, StatusToString(status))); + LogLine(QStringLiteral("RequestConnection(%1): %2") + .arg(endpoint, StatusToString(status))); + if (status != NearbyConnectionsQtFacade::Status::kSuccess) { + emit requestTrayMessage( + QStringLiteral("Send failed"), + QStringLiteral("Could not connect to %1").arg(peer)); + } + }, + Qt::QueuedConnection); + }); +} + +void FileShareTrayController::sendPendingFile(const QString& endpoint_id) { + const QString endpoint = endpoint_id.trimmed(); + QFileInfo file_info(pending_send_file_path_); + if (endpoint.isEmpty() || pending_send_file_path_.isEmpty() || + !file_info.exists() || !file_info.isFile()) { + SetStatus(QStringLiteral("Selected file is not available")); + emit requestTrayMessage(QStringLiteral("Send failed"), + QStringLiteral("Selected file is not available.")); + return; + } + + const QString peer = PeerLabelForEndpoint(endpoint); + const QString file_name = + pending_send_file_name_.isEmpty() ? file_info.fileName() + : pending_send_file_name_; + + // Send metadata message first to preserve file names on receiver side. + const QString metadata = QStringLiteral("FILE:%1").arg(file_name); + QByteArray metadata_bytes = metadata.toUtf8(); + std::vector metadata_vec(metadata_bytes.begin(), metadata_bytes.end()); + auto metadata_payload = service_.CreateBytesPayload(std::move(metadata_vec)); + + service_.SendPayload( + service_id_, {endpoint.toStdString()}, std::move(metadata_payload), + [this, endpoint](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, endpoint, status]() { + LogLine(QStringLiteral("Send metadata payload (%1): %2") + .arg(endpoint, StatusToString(status))); + }, + Qt::QueuedConnection); + }); + + NearbyConnectionsQtFacade::Payload file_payload; + file_payload.id = g_local_payload_id.fetch_add(1); + file_payload.type = NearbyConnectionsQtFacade::Payload::Type::kFile; + file_payload.file_path = file_info.absoluteFilePath().toStdString(); + file_payload.file_name = file_name.toStdString(); + file_payload.parent_folder = ""; + + const qlonglong payload_id = file_payload.id; + const qulonglong total_bytes = + static_cast(qMax(0, file_info.size())); + + UpsertTransfer(endpoint, payload_id, QStringLiteral("Queued"), 0, + total_bytes, QStringLiteral("outgoing")); + + outgoing_file_payload_to_endpoint_.insert(payload_id, endpoint); + outgoing_file_payload_to_name_.insert(payload_id, file_name); + send_terminal_notified_.remove(payload_id); + + emit requestTrayMessage( + QStringLiteral("Sending file"), + QStringLiteral("Sending %1 to %2").arg(file_name, peer)); + + service_.SendPayload( + service_id_, {endpoint.toStdString()}, std::move(file_payload), + [this, endpoint, payload_id, file_name, + total_bytes](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, endpoint, payload_id, file_name, status, total_bytes]() { + LogLine(QStringLiteral("Send file payload (%1, %2): %3") + .arg(endpoint) + .arg(payload_id) + .arg(StatusToString(status))); + + if (status == NearbyConnectionsQtFacade::Status::kSuccess) { + SetStatus(QStringLiteral("Sending %1...").arg(file_name)); + return; + } + + UpsertTransfer(endpoint, payload_id, QStringLiteral("SendFailed"), + 0, total_bytes, QStringLiteral("outgoing")); + + const QString peer = PeerLabelForEndpoint(endpoint); + emit requestTrayMessage( + QStringLiteral("Send failed"), + QStringLiteral("%1 failed to send to %2") + .arg(file_name, peer)); + + outgoing_file_payload_to_endpoint_.remove(payload_id); + outgoing_file_payload_to_name_.remove(payload_id); + send_terminal_notified_.remove(payload_id); + + if (!pending_send_file_path_.isEmpty()) { + pending_send_file_path_.clear(); + emit pendingSendFilePathChanged(); + } + if (!pending_send_file_name_.isEmpty()) { + pending_send_file_name_.clear(); + emit pendingSendFileNameChanged(); + } + + target_endpoint_for_send_.clear(); + disconnectDevice(endpoint); + }, + Qt::QueuedConnection); + }); +} + +void FileShareTrayController::disconnectDevice(const QString& endpoint_id) { + const QString endpoint = endpoint_id.trimmed(); + if (endpoint.isEmpty()) { + return; + } + + service_.DisconnectFromEndpoint( + service_id_, endpoint.toStdString(), + [this, endpoint](NearbyConnectionsQtFacade::Status status) { + QMetaObject::invokeMethod( + this, + [this, endpoint, status]() { + const QString peer = PeerLabelForEndpoint(endpoint); + LogLine(QStringLiteral("Disconnect(%1): %2") + .arg(endpoint, StatusToString(status))); + if (status == NearbyConnectionsQtFacade::Status::kSuccess) { + RemoveConnectedDevice(endpoint); + endpoint_mediums_.remove(endpoint); + emit endpointMediumsChanged(); + SetStatus(QStringLiteral("Disconnected from %1").arg(peer)); + } + }, + Qt::QueuedConnection); + }); +} + +void FileShareTrayController::AddDiscoveredDevice(const QString& endpoint_id) { + if (discovered_devices_.contains(endpoint_id)) { + return; + } + discovered_devices_.append(endpoint_id); + emit discoveredDevicesChanged(); +} + +void FileShareTrayController::RemoveDiscoveredDevice(const QString& endpoint_id) { + if (!discovered_devices_.removeOne(endpoint_id)) { + return; + } + emit discoveredDevicesChanged(); +} + +void FileShareTrayController::AddConnectedDevice(const QString& endpoint_id) { + if (connected_devices_.contains(endpoint_id)) { + return; + } + connected_devices_.append(endpoint_id); + emit connectedDevicesChanged(); +} + +void FileShareTrayController::RemoveConnectedDevice(const QString& endpoint_id) { + if (!connected_devices_.removeOne(endpoint_id)) { + return; + } + emit connectedDevicesChanged(); +} + +void FileShareTrayController::SetPeerNameForEndpoint(const QString& endpoint_id, + const QString& peer_name) { + const QString endpoint = endpoint_id.trimmed(); + if (endpoint.isEmpty()) { + return; + } + + const QString trimmed_name = peer_name.trimmed(); + const QString previous = endpoint_peer_names_.value(endpoint).trimmed(); + if (previous == trimmed_name) { + return; + } + + if (trimmed_name.isEmpty()) { + endpoint_peer_names_.remove(endpoint); + } else { + endpoint_peer_names_[endpoint] = trimmed_name; + } + + emit discoveredDevicesChanged(); + emit connectedDevicesChanged(); +} + +QString FileShareTrayController::PeerLabelForEndpoint(const QString& endpoint_id) const { + const QString endpoint = endpoint_id.trimmed(); + if (endpoint.isEmpty()) { + return QStringLiteral("Unknown device"); + } + + const QString peer_name = endpoint_peer_names_.value(endpoint).trimmed(); + return peer_name.isEmpty() ? QStringLiteral("Unknown device") : peer_name; +} + +QString FileShareTrayController::FinalizeReceivedFilePath( + const QString& received_path, const QString& received_file_name, + qlonglong payload_id) const { + const QString source = received_path.trimmed(); + if (source.isEmpty()) { + return source; + } + + QFileInfo source_info(source); + const QString source_abs = source_info.absoluteFilePath(); + const QString source_dir = source_info.absolutePath(); + + QString target_name = QFileInfo(received_file_name.trimmed()).fileName(); + if (target_name.isEmpty()) { + target_name = source_info.fileName(); + } + if (target_name.isEmpty()) { + target_name = QStringLiteral("payload_%1.bin").arg(payload_id); + } + + const QFileInfo target_name_info(target_name); + const QString stem = + target_name_info.completeBaseName().isEmpty() + ? target_name_info.fileName() + : target_name_info.completeBaseName(); + const QString suffix = target_name_info.completeSuffix(); + QString target_path = QDir(source_dir).filePath(target_name); + + int suffix_index = 1; + while (target_path != source_abs && QFileInfo::exists(target_path)) { + const QString next_name = + suffix.isEmpty() + ? QStringLiteral("%1_%2").arg(stem).arg(suffix_index) + : QStringLiteral("%1_%2.%3") + .arg(stem) + .arg(suffix_index) + .arg(suffix); + target_path = QDir(source_dir).filePath(next_name); + ++suffix_index; + } + + if (target_path == source_abs) { + return source_abs; + } + + if (QFile::rename(source_abs, target_path)) { + return target_path; + } + + if (QFile::copy(source_abs, target_path)) { + QFile::remove(source_abs); + return target_path; + } + + return source_abs; +} + +void FileShareTrayController::UpsertTransfer(const QString& endpoint_id, + qlonglong payload_id, + const QString& status, + qulonglong bytes_transferred, + qulonglong total_bytes, + const QString& direction) { + const QString medium = mediumForEndpoint(endpoint_id); + const double progress = + total_bytes > 0 + ? static_cast(bytes_transferred) / + static_cast(total_bytes) + : 0.0; + + QVariantMap transfer{{QStringLiteral("payloadId"), payload_id}, + {QStringLiteral("endpointId"), endpoint_id}, + {QStringLiteral("status"), status}, + {QStringLiteral("bytesTransferred"), bytes_transferred}, + {QStringLiteral("totalBytes"), total_bytes}, + {QStringLiteral("progress"), progress}, + {QStringLiteral("medium"), medium}, + {QStringLiteral("direction"), direction}}; + + if (transfer_row_for_payload_.contains(payload_id)) { + const int row = transfer_row_for_payload_.value(payload_id); + if (row >= 0 && row < transfers_.size()) { + transfers_[row] = transfer; + emit transfersChanged(); + return; + } + } + + transfer_row_for_payload_.insert(payload_id, transfers_.size()); + transfers_.append(transfer); + emit transfersChanged(); +} + +void FileShareTrayController::UpdateTransferMediumForEndpoint( + const QString& endpoint_id, const QString& medium) { + bool changed = false; + for (int i = 0; i < transfers_.size(); ++i) { + QVariantMap row = transfers_[i].toMap(); + if (row.value(QStringLiteral("endpointId")).toString() != endpoint_id) { + continue; + } + row[QStringLiteral("medium")] = medium; + transfers_[i] = row; + changed = true; + } + if (changed) { + emit transfersChanged(); + } +} + +void FileShareTrayController::SetStatus(const QString& status) { + if (status == status_message_) { + return; + } + status_message_ = status; + emit statusMessageChanged(); + LogLine(QStringLiteral("Status: %1").arg(status_message_)); +} + +bool FileShareTrayController::HasActiveTransfers() const { + for (const QVariant& row_value : transfers_) { + const QVariantMap row = row_value.toMap(); + const QString status = row.value(QStringLiteral("status")).toString(); + if (status == QStringLiteral("InProgress") || + status == QStringLiteral("Queued")) { + return true; + } + } + return false; +} + +void FileShareTrayController::LogLine(const QString& line) { + if (!log_file_.isOpen()) { + ReopenLogFile(); + } + if (!log_file_.isOpen()) { + return; + } + + QTextStream stream(&log_file_); + stream << QDateTime::currentDateTimeUtc().toString(Qt::ISODate) << " " << line + << "\n"; + stream.flush(); +} + +void FileShareTrayController::ReopenLogFile() { + if (log_file_.isOpen()) { + log_file_.close(); + } + log_file_.setFileName(log_path_); + log_file_.open(QIODevice::Append | QIODevice::Text | QIODevice::WriteOnly); +} + +QString FileShareTrayController::StatusToString( + NearbyConnectionsQtFacade::Status status) { + switch (status) { + case NearbyConnectionsQtFacade::Status::kSuccess: + return QStringLiteral("Success"); + case NearbyConnectionsQtFacade::Status::kError: + return QStringLiteral("Error"); + case NearbyConnectionsQtFacade::Status::kOutOfOrderApiCall: + return QStringLiteral("OutOfOrderApiCall"); + case NearbyConnectionsQtFacade::Status::kAlreadyHaveActiveStrategy: + return QStringLiteral("AlreadyHaveActiveStrategy"); + case NearbyConnectionsQtFacade::Status::kAlreadyAdvertising: + return QStringLiteral("AlreadyAdvertising"); + case NearbyConnectionsQtFacade::Status::kAlreadyDiscovering: + return QStringLiteral("AlreadyDiscovering"); + case NearbyConnectionsQtFacade::Status::kAlreadyListening: + return QStringLiteral("AlreadyListening"); + case NearbyConnectionsQtFacade::Status::kEndpointIOError: + return QStringLiteral("EndpointIOError"); + case NearbyConnectionsQtFacade::Status::kEndpointUnknown: + return QStringLiteral("EndpointUnknown"); + case NearbyConnectionsQtFacade::Status::kConnectionRejected: + return QStringLiteral("ConnectionRejected"); + case NearbyConnectionsQtFacade::Status::kAlreadyConnectedToEndpoint: + return QStringLiteral("AlreadyConnectedToEndpoint"); + case NearbyConnectionsQtFacade::Status::kNotConnectedToEndpoint: + return QStringLiteral("NotConnectedToEndpoint"); + case NearbyConnectionsQtFacade::Status::kBluetoothError: + return QStringLiteral("BluetoothError"); + case NearbyConnectionsQtFacade::Status::kBleError: + return QStringLiteral("BleError"); + case NearbyConnectionsQtFacade::Status::kWifiLanError: + return QStringLiteral("WifiLanError"); + case NearbyConnectionsQtFacade::Status::kPayloadUnknown: + return QStringLiteral("PayloadUnknown"); + case NearbyConnectionsQtFacade::Status::kReset: + return QStringLiteral("Reset"); + case NearbyConnectionsQtFacade::Status::kTimeout: + return QStringLiteral("Timeout"); + case NearbyConnectionsQtFacade::Status::kUnknown: + return QStringLiteral("Unknown"); + case NearbyConnectionsQtFacade::Status::kNextValue: + return QStringLiteral("NextValue"); + } + return QStringLiteral("Unknown"); +} + +QString FileShareTrayController::PayloadStatusToString( + NearbyConnectionsQtFacade::PayloadStatus status) { + switch (status) { + case NearbyConnectionsQtFacade::PayloadStatus::kSuccess: + return QStringLiteral("Success"); + case NearbyConnectionsQtFacade::PayloadStatus::kFailure: + return QStringLiteral("Failure"); + case NearbyConnectionsQtFacade::PayloadStatus::kInProgress: + return QStringLiteral("InProgress"); + case NearbyConnectionsQtFacade::PayloadStatus::kCanceled: + return QStringLiteral("Canceled"); + } + return QStringLiteral("Unknown"); +} + +QString FileShareTrayController::MediumToString(NearbyConnectionsQtFacade::Medium medium) { + switch (medium) { + case NearbyConnectionsQtFacade::Medium::kUnknown: + return QStringLiteral("Unknown"); + case NearbyConnectionsQtFacade::Medium::kMdns: + return QStringLiteral("mDNS"); + case NearbyConnectionsQtFacade::Medium::kBluetooth: + return QStringLiteral("Bluetooth"); + case NearbyConnectionsQtFacade::Medium::kWifiHotspot: + return QStringLiteral("WiFiHotspot"); + case NearbyConnectionsQtFacade::Medium::kBle: + return QStringLiteral("BLE"); + case NearbyConnectionsQtFacade::Medium::kWifiLan: + return QStringLiteral("WiFiLAN"); + case NearbyConnectionsQtFacade::Medium::kWifiAware: + return QStringLiteral("WiFiAware"); + case NearbyConnectionsQtFacade::Medium::kNfc: + return QStringLiteral("NFC"); + case NearbyConnectionsQtFacade::Medium::kWifiDirect: + return QStringLiteral("WiFiDirect"); + case NearbyConnectionsQtFacade::Medium::kWebRtc: + return QStringLiteral("WebRTC"); + case NearbyConnectionsQtFacade::Medium::kBleL2Cap: + return QStringLiteral("BLEL2CAP"); + } + return QStringLiteral("Unknown"); +} diff --git a/sharing/linux/qml_tray_app/file_share_tray_controller.h b/sharing/linux/qml_tray_app/file_share_tray_controller.h new file mode 100644 index 00000000..a6aaec69 --- /dev/null +++ b/sharing/linux/qml_tray_app/file_share_tray_controller.h @@ -0,0 +1,160 @@ +#ifndef SHARING_LINUX_QML_TRAY_APP_FILE_SHARE_TRAY_CONTROLLER_H_ +#define SHARING_LINUX_QML_TRAY_APP_FILE_SHARE_TRAY_CONTROLLER_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "sharing/linux/nearby_connections_qt_facade.h" + +using NearbyConnectionsQtFacade = nearby::sharing::NearbyConnectionsQtFacade; + +class FileShareTrayController : public QObject { + Q_OBJECT + Q_PROPERTY(QString mode READ mode NOTIFY modeChanged) + Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged) + Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged) + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(QString pendingSendFileName READ pendingSendFileName NOTIFY pendingSendFileNameChanged) + Q_PROPERTY(QString pendingSendFilePath READ pendingSendFilePath NOTIFY pendingSendFilePathChanged) + Q_PROPERTY(QStringList discoveredDevices READ discoveredDevices NOTIFY discoveredDevicesChanged) + Q_PROPERTY(QStringList connectedDevices READ connectedDevices NOTIFY connectedDevicesChanged) + Q_PROPERTY(QVariantMap endpointMediums READ endpointMediums NOTIFY endpointMediumsChanged) + Q_PROPERTY(QVariantList transfers READ transfers NOTIFY transfersChanged) + + public: + explicit FileShareTrayController(QObject* parent = nullptr); + ~FileShareTrayController() override; + + QString mode() const { return mode_; } + + QString deviceName() const { return device_name_; } + void setDeviceName(const QString& device_name); + + QString statusMessage() const { return status_message_; } + bool running() const { return running_; } + + QString pendingSendFileName() const { return pending_send_file_name_; } + QString pendingSendFilePath() const { return pending_send_file_path_; } + + QStringList discoveredDevices() const { return discovered_devices_; } + QStringList connectedDevices() const { return connected_devices_; } + QVariantMap endpointMediums() const { return endpoint_mediums_; } + QVariantList transfers() const { return transfers_; } + + Q_INVOKABLE void start(); + Q_INVOKABLE void stop(); + Q_INVOKABLE void switchToReceiveMode(); + Q_INVOKABLE void switchToSendModeWithFile(const QString& file_path); + Q_INVOKABLE void sendPendingFileToEndpoint(const QString& endpoint_id); + Q_INVOKABLE QString mediumForEndpoint(const QString& endpoint_id) const; + Q_INVOKABLE QString peerNameForEndpoint(const QString& endpoint_id) const; + Q_INVOKABLE void clearTransfers(); + Q_INVOKABLE void hideToTray(); + + signals: + void modeChanged(); + void deviceNameChanged(); + void statusMessageChanged(); + void runningChanged(); + void pendingSendFileNameChanged(); + void pendingSendFilePathChanged(); + void discoveredDevicesChanged(); + void connectedDevicesChanged(); + void endpointMediumsChanged(); + void transfersChanged(); + + void requestTrayMessage(const QString& title, const QString& body); + + private: + void startSendMode(); + void startReceiveMode(); + void LoadSettings(); + void SaveSettings() const; + + std::vector BuildEndpointInfo() const; + + NearbyConnectionsQtFacade::ConnectionListener BuildConnectionListener(); + NearbyConnectionsQtFacade::DiscoveryListener BuildDiscoveryListener(); + NearbyConnectionsQtFacade::PayloadListener BuildPayloadListener(); + + NearbyConnectionsQtFacade::AdvertisingOptions BuildAdvertisingOptions() const; + NearbyConnectionsQtFacade::DiscoveryOptions BuildDiscoveryOptions() const; + NearbyConnectionsQtFacade::ConnectionOptions BuildConnectionOptions() const; + NearbyConnectionsQtFacade::MediumSelection BuildMediumSelection() const; + + void acceptIncomingInternal(const QString& endpoint_id); + void requestConnectionForSend(const QString& endpoint_id); + void sendPendingFile(const QString& endpoint_id); + void disconnectDevice(const QString& endpoint_id); + + void AddDiscoveredDevice(const QString& endpoint_id); + void RemoveDiscoveredDevice(const QString& endpoint_id); + void AddConnectedDevice(const QString& endpoint_id); + void RemoveConnectedDevice(const QString& endpoint_id); + void SetPeerNameForEndpoint(const QString& endpoint_id, + const QString& peer_name); + QString PeerLabelForEndpoint(const QString& endpoint_id) const; + + QString FinalizeReceivedFilePath(const QString& received_path, + const QString& received_file_name, + qlonglong payload_id) const; + + void UpsertTransfer(const QString& endpoint_id, qlonglong payload_id, + const QString& status, qulonglong bytes_transferred, + qulonglong total_bytes, const QString& direction); + void UpdateTransferMediumForEndpoint(const QString& endpoint_id, + const QString& medium); + + void SetStatus(const QString& status); + bool HasActiveTransfers() const; + void LogLine(const QString& line); + void ReopenLogFile(); + + static QString StatusToString(NearbyConnectionsQtFacade::Status status); + static QString PayloadStatusToString( + NearbyConnectionsQtFacade::PayloadStatus status); + static QString MediumToString(NearbyConnectionsQtFacade::Medium medium); + + NearbyConnectionsQtFacade service_; + + QString mode_ = QStringLiteral("Receive"); + QString device_name_ = QStringLiteral("NearbyQtFile"); + const std::string service_id_ = "com.nearby.qml.tray"; + QString status_message_ = QStringLiteral("Idle"); + bool running_ = false; + + QString pending_send_file_path_; + QString pending_send_file_name_; + QString target_endpoint_for_send_; + + QStringList discovered_devices_; + QStringList connected_devices_; + QHash endpoint_peer_names_; + QVariantMap endpoint_mediums_; + + QVariantList transfers_; + QHash transfer_row_for_payload_; + + QHash pending_file_names_; + QHash incoming_file_paths_; + QHash incoming_file_names_; + QHash incoming_file_endpoints_; + QHash outgoing_file_payload_to_endpoint_; + QHash outgoing_file_payload_to_name_; + QSet send_terminal_notified_; + + QString log_path_ = QStringLiteral("/tmp/nearby_qml_file_tray.log"); + QFile log_file_; +}; + +#endif // SHARING_LINUX_QML_TRAY_APP_FILE_SHARE_TRAY_CONTROLLER_H_ diff --git a/sharing/linux/qml_tray_app/file_share_tray_main.cpp b/sharing/linux/qml_tray_app/file_share_tray_main.cpp new file mode 100644 index 00000000..d4b56d0d --- /dev/null +++ b/sharing/linux/qml_tray_app/file_share_tray_main.cpp @@ -0,0 +1,169 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "file_share_tray_controller.h" + +namespace { + +QIcon BuildTintedSymbolicIcon(const QString& source, const QColor& color) { + QIcon source_icon(source); + if (source_icon.isNull()) { + return QIcon(); + } + + QIcon tinted_icon; + for (int size : {16, 18, 20, 22, 24, 32}) { + QPixmap pixmap = source_icon.pixmap(size, size); + if (pixmap.isNull()) { + continue; + } + + QPixmap tinted(pixmap.size()); + tinted.fill(Qt::transparent); + + QPainter painter(&tinted); + painter.drawPixmap(0, 0, pixmap); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(tinted.rect(), color); + painter.end(); + + tinted_icon.addPixmap(tinted); + } + + return tinted_icon; +} + +} // namespace + +int main(int argc, char* argv[]) { + nearby::sharing::NearbyConnectionsQtFacade::SetBleL2capFlagOverrides( + true, false); + + QApplication app(argc, argv); + app.setQuitOnLastWindowClosed(false); + + FileShareTrayController controller; + + QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("fileShareController", &controller); + engine.load(QUrl(QStringLiteral("qrc:/qml/FileShareTray.qml"))); + if (engine.rootObjects().isEmpty()) { + return 1; + } + + + auto* window = qobject_cast(engine.rootObjects().first()); + if (window == nullptr) { + return 1; + } + + const auto resolve_tray_icon = [&app]() { + const QColor symbolic_color = app.palette().color(QPalette::WindowText); + QIcon tray_icon = BuildTintedSymbolicIcon( + QStringLiteral(":/icons/tray_icon-symbolic.svg"), symbolic_color); + if (tray_icon.isNull()) { + tray_icon = QIcon::fromTheme(QStringLiteral("network-wireless-symbolic")); + } + if (tray_icon.isNull()) { + tray_icon = QIcon(QStringLiteral(":/icons/tray_icon.png")); + } + if (tray_icon.isNull()) { + tray_icon = app.windowIcon(); + } + return tray_icon; + }; + + QSystemTrayIcon tray(resolve_tray_icon()); + tray.setToolTip(QStringLiteral("Nearby File Tray")); + + QMenu tray_menu; + QAction* send_action = tray_menu.addAction(QStringLiteral("Send")); + QAction* receive_action = tray_menu.addAction(QStringLiteral("Receive")); + tray_menu.addSeparator(); + QAction* show_action = tray_menu.addAction(QStringLiteral("Show")); + QAction* hide_action = tray_menu.addAction(QStringLiteral("Hide")); + tray_menu.addSeparator(); + QAction* quit_action = tray_menu.addAction(QStringLiteral("Quit")); + + QObject::connect(send_action, &QAction::triggered, window, + [&controller, window]() { + const QString file = QFileDialog::getOpenFileName( + nullptr, QStringLiteral("Select file to send")); + if (file.isEmpty()) { + return; + } + controller.switchToSendModeWithFile(file); + window->show(); + window->raise(); + window->requestActivate(); + }); + + QObject::connect(receive_action, &QAction::triggered, + [&controller, window]() { + controller.switchToReceiveMode(); + window->show(); + window->raise(); + window->requestActivate(); + }); + + QObject::connect(show_action, &QAction::triggered, window, [window]() { + window->show(); + window->raise(); + window->requestActivate(); + }); + + QObject::connect(hide_action, &QAction::triggered, window, [window]() { + window->hide(); + }); + + QObject::connect(quit_action, &QAction::triggered, &app, + [&controller, &app]() { + controller.stop(); + app.quit(); + }); + + QObject::connect(&tray, &QSystemTrayIcon::activated, window, + [window](QSystemTrayIcon::ActivationReason reason) { + if (reason != QSystemTrayIcon::Trigger && + reason != QSystemTrayIcon::DoubleClick) { + return; + } + if (window->isVisible()) { + window->hide(); + } else { + window->show(); + window->raise(); + window->requestActivate(); + } + }); + + QObject::connect(&controller, &FileShareTrayController::requestTrayMessage, + &tray, [&tray](const QString& title, const QString& body) { + tray.showMessage(title, body, QSystemTrayIcon::Information, + 4000); + }); + + QObject::connect(&app, &QCoreApplication::aboutToQuit, &controller, + [&controller]() { controller.stop(); }); + QObject::connect(app.styleHints(), &QStyleHints::colorSchemeChanged, &tray, + [&tray, &resolve_tray_icon](Qt::ColorScheme) { + tray.setIcon(resolve_tray_icon()); + }); + + tray.setContextMenu(&tray_menu); + tray.show(); + + controller.switchToReceiveMode(); + + return app.exec(); +} diff --git a/sharing/linux/qml_tray_app/main.cpp b/sharing/linux/qml_tray_app/main.cpp index f1db84cd..a07319aa 100644 --- a/sharing/linux/qml_tray_app/main.cpp +++ b/sharing/linux/qml_tray_app/main.cpp @@ -2,12 +2,47 @@ #include #include #include +#include +#include #include #include #include +#include #include -#include "sharing/linux/qml_tray_app/nearby_tray_controller.h" +#include "nearby_tray_controller.h" + +namespace { + +QIcon BuildTintedSymbolicIcon(const QString& source, const QColor& color) { + QIcon source_icon(source); + if (source_icon.isNull()) { + return QIcon(); + } + + QIcon tinted_icon; + for (int size : {16, 18, 20, 22, 24, 32}) { + QPixmap pixmap = source_icon.pixmap(size, size); + if (pixmap.isNull()) { + continue; + } + + QPixmap tinted(pixmap.size()); + tinted.fill(Qt::transparent); + + QPainter painter(&tinted); + painter.drawPixmap(0, 0, pixmap); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(tinted.rect(), color); + painter.end(); + + tinted_icon.addPixmap(tinted); + } + + return tinted_icon; +} + +} // namespace int main(int argc, char* argv[]) { QApplication app(argc, argv); @@ -27,23 +62,30 @@ int main(int argc, char* argv[]) { return 1; } - // Try custom symbolic icon first (will auto-theme if available) - QIcon tray_icon = QIcon(QStringLiteral(":/icons/tray_icon-symbolic.svg")); - - // Fallback to system themed icon - if (tray_icon.isNull()) { - tray_icon = QIcon::fromTheme(QStringLiteral("network-wireless-symbolic")); - } - - // Fallback to custom PNG icon - if (tray_icon.isNull()) { - tray_icon = QIcon(QStringLiteral(":/icons/tray_icon.png")); - } - - // Final fallback - if (tray_icon.isNull()) { - tray_icon = app.windowIcon(); - } + const auto resolve_tray_icon = [&app]() { + // Render the bundled symbolic icon with palette text color so it adapts to light/dark themes. + const QColor symbolic_color = app.palette().color(QPalette::WindowText); + QIcon tray_icon = BuildTintedSymbolicIcon( + QStringLiteral(":/icons/tray_icon-symbolic.svg"), symbolic_color); + + // Fallback to system themed icon. + if (tray_icon.isNull()) { + tray_icon = QIcon::fromTheme(QStringLiteral("network-wireless-symbolic")); + } + + // Fallback to custom PNG icon. + if (tray_icon.isNull()) { + tray_icon = QIcon(QStringLiteral(":/icons/tray_icon.png")); + } + + // Final fallback. + if (tray_icon.isNull()) { + tray_icon = app.windowIcon(); + } + return tray_icon; + }; + + QIcon tray_icon = resolve_tray_icon(); QSystemTrayIcon tray(tray_icon); tray.setToolTip(QStringLiteral("Nearby QML Tray")); @@ -89,6 +131,10 @@ int main(int argc, char* argv[]) { QObject::connect(&app, &QCoreApplication::aboutToQuit, &controller, [&controller]() { controller.stop(); }); + QObject::connect(app.styleHints(), &QStyleHints::colorSchemeChanged, &tray, + [&tray, &resolve_tray_icon](Qt::ColorScheme) { + tray.setIcon(resolve_tray_icon()); + }); tray.setContextMenu(&tray_menu); tray.show(); diff --git a/sharing/linux/qml_tray_app/nearby_tray_controller.cc b/sharing/linux/qml_tray_app/nearby_tray_controller.cc index 1f15d05a..18c0fef0 100644 --- a/sharing/linux/qml_tray_app/nearby_tray_controller.cc +++ b/sharing/linux/qml_tray_app/nearby_tray_controller.cc @@ -1,4 +1,4 @@ -#include "sharing/linux/qml_tray_app/nearby_tray_controller.h" +#include "nearby_tray_controller.h" #include #include @@ -13,7 +13,7 @@ namespace { using NearbyConnectionsQtFacade = - nearby::sharing::linux::NearbyConnectionsQtFacade; + nearby::sharing::NearbyConnectionsQtFacade; QString NormalizeMediumsMode(QString mode) { mode = mode.trimmed().toLower(); diff --git a/sharing/linux/qml_tray_app/nearby_tray_controller.h b/sharing/linux/qml_tray_app/nearby_tray_controller.h index 545d54c9..257e3a63 100644 --- a/sharing/linux/qml_tray_app/nearby_tray_controller.h +++ b/sharing/linux/qml_tray_app/nearby_tray_controller.h @@ -15,6 +15,8 @@ #include "sharing/linux/nearby_connections_qt_facade.h" +using NearbyConnectionsQtFacade = nearby::sharing::NearbyConnectionsQtFacade; + class NearbyTrayController : public QObject { Q_OBJECT Q_PROPERTY(QString mode READ mode WRITE setMode NOTIFY modeChanged) @@ -132,21 +134,14 @@ class NearbyTrayController : public QObject { std::vector BuildEndpointInfo() const; - nearby::sharing::linux::NearbyConnectionsQtFacade::ConnectionListener - BuildConnectionListener(); - nearby::sharing::linux::NearbyConnectionsQtFacade::DiscoveryListener - BuildDiscoveryListener(); - nearby::sharing::linux::NearbyConnectionsQtFacade::PayloadListener - BuildPayloadListener(); + NearbyConnectionsQtFacade::ConnectionListener BuildConnectionListener(); + NearbyConnectionsQtFacade::DiscoveryListener BuildDiscoveryListener(); + NearbyConnectionsQtFacade::PayloadListener BuildPayloadListener(); - nearby::sharing::linux::NearbyConnectionsQtFacade::AdvertisingOptions - BuildAdvertisingOptions() const; - nearby::sharing::linux::NearbyConnectionsQtFacade::DiscoveryOptions - BuildDiscoveryOptions() const; - nearby::sharing::linux::NearbyConnectionsQtFacade::ConnectionOptions - BuildConnectionOptions() const; - nearby::sharing::linux::NearbyConnectionsQtFacade::MediumSelection - BuildMediumSelection() const; + NearbyConnectionsQtFacade::AdvertisingOptions BuildAdvertisingOptions() const; + NearbyConnectionsQtFacade::DiscoveryOptions BuildDiscoveryOptions() const; + NearbyConnectionsQtFacade::ConnectionOptions BuildConnectionOptions() const; + NearbyConnectionsQtFacade::MediumSelection BuildMediumSelection() const; void AddDiscoveredDevice(const QString& endpoint_id); void RemoveDiscoveredDevice(const QString& endpoint_id); @@ -171,14 +166,12 @@ class NearbyTrayController : public QObject { void LogLine(const QString& line); void ReopenLogFile(); - static QString StatusToString( - nearby::sharing::linux::NearbyConnectionsQtFacade::Status status); + static QString StatusToString(NearbyConnectionsQtFacade::Status status); static QString PayloadStatusToString( - nearby::sharing::linux::NearbyConnectionsQtFacade::PayloadStatus status); - static QString MediumToString( - nearby::sharing::linux::NearbyConnectionsQtFacade::Medium medium); + NearbyConnectionsQtFacade::PayloadStatus status); + static QString MediumToString(NearbyConnectionsQtFacade::Medium medium); - nearby::sharing::linux::NearbyConnectionsQtFacade service_; + NearbyConnectionsQtFacade service_; QString mode_ = QStringLiteral("Receive"); QString device_name_ = QStringLiteral("NearbyQt"); diff --git a/sharing/linux/qml_tray_app/resources_file_share.qrc b/sharing/linux/qml_tray_app/resources_file_share.qrc new file mode 100644 index 00000000..cf190a44 --- /dev/null +++ b/sharing/linux/qml_tray_app/resources_file_share.qrc @@ -0,0 +1,9 @@ + + + FileShareTray.qml + + + tray_icon.png + tray_icon-symbolic.svg + + diff --git a/sharing/linux/qml_tray_app/tray_icon-symbolic.svg b/sharing/linux/qml_tray_app/tray_icon-symbolic.svg new file mode 100644 index 00000000..645e746a --- /dev/null +++ b/sharing/linux/qml_tray_app/tray_icon-symbolic.svg @@ -0,0 +1,14 @@ + + + + diff --git a/sharing/linux/qml_tray_app/tray_icon.png b/sharing/linux/qml_tray_app/tray_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e9e17f54944c30e1d0ee794d44bbcc253a03acc8 GIT binary patch literal 2092 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq8sKd6mGxU^Rn*LA+qju0R{0zJCEe zA+A6LqhMr)!2kdMZI`mt0X@H>B*-tAfsuoUo1IlTe&*hzhj;Y)OKD7f^ykO5YVlit z4>}ire*59qV_&w)FK27r3{Cy|?>bgID^;H;`Tc1?`PZ6HPkg3amnnO2`)9D(1g+4W z2FLkyUY_D-5tbABwx83gY3=!QYZ@))87+9HFM8r%N@i*a*TeU->T^xxlily=HVa%% z@bYAmh`1WtQl)qA(42g8OQ+M?yEJ?5-!u*^_~;!}H0`;IL-gC*}UpP-4Gj5Z5g^wJd-4K#Gr-6~?Z`@Qq-_qXgf-(>&9SJN=x|1>A=Pn`pCMD} zw>dr)+s)XIJDK7Vb> zugkAL{(gQXCDi7yQBPXIohNAyU-mSYY*rH}(Pd!{Qccu($2R@XhP|b;+vCe7*G2cp z-RW8{w{7)B%grYfllLCJZ0!GG+3q#!w`G)Xyb&ng{`%3E6W32v#fnC$&zFAqL2dQ% z+Wx-FkK>cfWF{g?fs)+|r!s2iXEdHg#2 z>!hX^M4>QXt?%^LSS zd)%*>_s+UJpY{Ia2c4hR+PJg)b)Ki~&7~ecY4hB#!M|RApTA%71KCiVpH@YJFJnVhX)rf05a zp=+rCw$i{z*T7IA(9ldFqokz3N?*Ucyj(96q#B5Us`c`V(jBWGodB9B0WvWpqck_k z%E~1_ximL5uf)nK0I0N>!EpKi-TG*1z;;?$Wu#`NXOu9Q8X6tA5D*AdBaUPaR84qh zN=XJtiA)Xi9iS3PBqhF?xv3?U1*r^RSLqkzrQ2@`^FuSoHw37P!O+y)%*@ij#Mofz zFV=5BCBjJN1ZP$ORT$}+dpkKf6$Ntxr5%u@LxVgS(lT>WfkB|BUs{lppO{jtZ)&C= zR+eR9VpO4TWMFBbpOcxF9iN$;pBHavWMN@st_O6Dj7M=R&@2W|S3j3^P6