diff --git a/components/waydroidintegrationplugin/CMakeLists.txt b/components/waydroidintegrationplugin/CMakeLists.txt index 2e559378..023256bf 100644 --- a/components/waydroidintegrationplugin/CMakeLists.txt +++ b/components/waydroidintegrationplugin/CMakeLists.txt @@ -2,7 +2,7 @@ # SPDX-License-Identifier: BSD-2-Clause ecm_add_qml_module(waydroidintegrationplugin URI org.kde.plasma.private.mobileshell.waydroidintegrationplugin GENERATE_PLUGIN_SOURCE) -target_sources(waydroidintegrationplugin PRIVATE waydroidstate.cpp) +target_sources(waydroidintegrationplugin PRIVATE waydroidstate.cpp waydroidapplication.cpp waydroidapplicationlistmodel.cpp) target_link_libraries(waydroidintegrationplugin PRIVATE Qt::Gui @@ -22,4 +22,4 @@ ecm_qt_declare_logging_category(waydroidintegrationplugin DESCRIPTION "Plugin to add Waydroid Integration on Plasma" ) -add_subdirectory(kauth) \ No newline at end of file +add_subdirectory(kauth) diff --git a/components/waydroidintegrationplugin/waydroidapplication.cpp b/components/waydroidintegrationplugin/waydroidapplication.cpp new file mode 100644 index 00000000..08278544 --- /dev/null +++ b/components/waydroidintegrationplugin/waydroidapplication.cpp @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2025 Florian RICHER + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include + +#include +#include + +using namespace Qt::StringLiterals; + +static const QRegularExpression nameRegExp(u"^Name:\\s*(\\S+)"_s); +static const QRegularExpression packageNameRegExp(u"^packageName:\\s*(\\S+)"_s); + +WaydroidApplication::WaydroidApplication(QObject *parent) + : QObject{parent} +{ + // Nothing +} + +WaydroidApplication::Ptr WaydroidApplication::fromWaydroidLog(QTextStream &inFile) +{ + WaydroidApplication::Ptr app; + + const QString line = inFile.readLine(); + const QRegularExpressionMatch nameMatch = nameRegExp.match(line); + + if (!nameMatch.hasMatch() || nameMatch.lastCapturedIndex() == 0) { + return nullptr; + } + + app = std::make_shared(); + app->m_name = nameMatch.captured(nameMatch.lastCapturedIndex()); + + qint64 oldPos = inFile.pos(); + while (!inFile.atEnd()) { + const QString line = inFile.readLine(); + if (line.trimmed().isEmpty()) { + continue; + } + + const QRegularExpressionMatch nameMatch = nameRegExp.match(line); + if (nameMatch.hasMatch()) { + inFile.seek(oldPos); // Revert file cursor position for the next Application parsing + return app; + } + + const QRegularExpressionMatch packageNameMatch = packageNameRegExp.match(line); + if (packageNameMatch.hasMatch() && packageNameMatch.lastCapturedIndex() > 0) { + app->m_packageName = packageNameMatch.captured(packageNameMatch.lastCapturedIndex()); + } + + oldPos = inFile.pos(); + } + + return app; +} + +QString WaydroidApplication::name() const +{ + return m_name; +} + +QString WaydroidApplication::packageName() const +{ + return m_packageName; +} diff --git a/components/waydroidintegrationplugin/waydroidapplication.h b/components/waydroidintegrationplugin/waydroidapplication.h new file mode 100644 index 00000000..b100fe42 --- /dev/null +++ b/components/waydroidintegrationplugin/waydroidapplication.h @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2025 Florian RICHER + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +class WaydroidApplication : public QObject, public std::enable_shared_from_this +{ + Q_OBJECT + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString packageName READ packageName CONSTANT) + +public: + typedef std::shared_ptr Ptr; + + WaydroidApplication(QObject *parent = nullptr); + + /** + * @brief Read from "waydroid app list" command output log through QTextStream. + * The QTextStream cursor must be set to the first line of the application. + * The first line begin with "Name:". + * + * @param inFile The QTextStream used to read line by line the Waydroid logs. + */ + static WaydroidApplication::Ptr fromWaydroidLog(QTextStream &inFile); + + QString name() const; + QString packageName() const; + +private: + QString m_name; + QString m_packageName; +}; diff --git a/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp new file mode 100644 index 00000000..cb47b295 --- /dev/null +++ b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2025 Florian RICHER + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "waydroidapplicationlistmodel.h" +#include "waydroidintegrationplugin_debug.h" +#include "waydroidshared.h" + +#include +#include +#include + +#include + +using namespace Qt::StringLiterals; +using namespace std::chrono_literals; + +WaydroidApplicationListModel::WaydroidApplicationListModel(WaydroidState *parent) + : QAbstractListModel{parent} + , m_waydroidState{parent} + , m_refreshTimer{new QTimer{this}} +{ + // Waydroid does not return all installed applications immediately, so we need to refresh regularly. + m_refreshTimer->setInterval(1s); + m_refreshTimer->setSingleShot(false); + m_refreshTimer->start(); + + connect(m_refreshTimer, &QTimer::timeout, this, &WaydroidApplicationListModel::refreshApplications); + connect(parent, &WaydroidState::sessionStatusChanged, this, &WaydroidApplicationListModel::refreshApplications); +} + +WaydroidApplicationListModel::~WaydroidApplicationListModel() = default; + +void WaydroidApplicationListModel::refreshApplications() +{ + if (m_waydroidState->sessionStatus() != WaydroidState::SessionRunning) { + return; + } + + qCDebug(WAYDROIDINTEGRATIONPLUGIN) << "Reload waydroid apps"; + + QMap appIdMap; // + for (int i = 0; i < m_applications.size(); ++i) { + const auto &application = m_applications[i]; + appIdMap.insert(application->packageName(), i); + } + + QList currentApps = queryApplications(); + QList toInsert; + + for (const WaydroidApplication::Ptr &application : currentApps) { + auto it = appIdMap.find(application->packageName()); + if (it != appIdMap.end()) { + // Application already in m_applications + appIdMap.erase(it); + } else { + // Application needs to be inserted into m_applications + toInsert.append(std::move(application)); + } + } + + QList toRemove; + for (int index : appIdMap.values()) { + toRemove.append(index); + } + + std::sort(toRemove.begin(), toRemove.end()); + + // Remove indices first, from end to start to avoid indicies changing + for (int i = toRemove.size() - 1; i >= 0; --i) { + int ind = toRemove[i]; + + beginRemoveRows({}, ind, ind); + m_applications.removeAt(ind); + endRemoveRows(); + } + + // Append new elements + for (const WaydroidApplication::Ptr &application : toInsert) { + beginInsertRows({}, m_applications.size(), m_applications.size()); + m_applications.append(application); + endInsertRows(); + } +} + +QList WaydroidApplicationListModel::queryApplications() const +{ + QList applications; + + QStringList arguments = {u"app"_s, u"list"_s}; + + QProcess *process = new QProcess(m_waydroidState); + process->start(WAYDROID_COMMAND, arguments); + process->waitForFinished(); + + if (process->exitCode() != 0) { + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to run waydroid app list command: " << process->readAllStandardError(); + return applications; + } + + const QByteArray data = process->readAllStandardOutput(); + if (data.isEmpty()) { + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Empty data: " << process->readAllStandardError(); + return applications; + } + + qCDebug(WAYDROIDINTEGRATIONPLUGIN) << "Waydroid output: " << data; + QTextStream output = QTextStream(data); + + while (!output.atEnd()) { + const WaydroidApplication::Ptr app = WaydroidApplication::fromWaydroidLog(output); + if (app == nullptr) { + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to fetch the application: Maybe wrong QTextStream cursor position."; + break; + } + + qCDebug(WAYDROIDINTEGRATIONPLUGIN) << "Waydroid application found: " << app.get()->name() << " (" << app.get()->packageName() << ")"; + applications.append(app); + } + return applications; +} + +QHash WaydroidApplicationListModel::roleNames() const +{ + return {{DelegateRole, QByteArrayLiteral("delegate")}, {NameRole, QByteArrayLiteral("name")}, {IdRole, QByteArrayLiteral("id")}}; +} + +QVariant WaydroidApplicationListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_applications.count()) { + return QVariant(); + } + + WaydroidApplication::Ptr app = m_applications.at(index.row()); + + switch (role) { + case Qt::DisplayRole: + case DelegateRole: + return QVariant::fromValue(app.get()); + case NameRole: + return app->name(); + case IdRole: + return app->packageName(); + default: + return QVariant(); + } +} + +int WaydroidApplicationListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return m_applications.count(); +} + +void WaydroidApplicationListModel::installApk(const QString apkFile) +{ + const QStringList arguments{u"app"_s, u"install"_s, apkFile}; + + QProcess *process = new QProcess(this); + process->start(WAYDROID_COMMAND, arguments); + + connect(process, &QProcess::finished, this, [this, apkFile, process](int exitCode, QProcess::ExitStatus exitStatus) { + if (exitCode == 0 && exitStatus == QProcess::NormalExit) { + Q_EMIT actionFinished(i18n("Application has been installed")); + } else { + Q_EMIT errorOccurred(i18n("Installation Failed")); + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Error occured during installation of " << apkFile << ": " << process->readAllStandardError(); + } + }); +} + +void WaydroidApplicationListModel::deleteApplication(const QString appId) +{ + const QStringList arguments{u"app"_s, u"remove"_s, appId}; + + QProcess *process = new QProcess(this); + process->start(WAYDROID_COMMAND, arguments); + + connect(process, &QProcess::finished, this, [this, appId, process](int exitCode, QProcess::ExitStatus exitStatus) { + Q_UNUSED(exitCode); + Q_UNUSED(exitStatus); + + const QByteArray errorLog = process->readAllStandardError(); + + // "waydroid app remove" send log on stderr but keep exitCode to 0 + if (errorLog.isEmpty()) { + Q_EMIT actionFinished(i18n("Application has been deleted")); + } else { + Q_EMIT errorOccurred(i18n("Application uninstall failed")); + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Error occured during uninstallation of " << appId << ": " << errorLog; + } + }); +} \ No newline at end of file diff --git a/components/waydroidintegrationplugin/waydroidapplicationlistmodel.h b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.h new file mode 100644 index 00000000..5c058179 --- /dev/null +++ b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.h @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2025 Florian RICHER + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "waydroidapplication.h" +#include "waydroidstate.h" + +#include +#include +#include + +class WaydroidState; + +class WaydroidApplicationListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + DelegateRole = Qt::UserRole + 1, + NameRole, + IdRole + }; + + WaydroidApplicationListModel(WaydroidState *parent = nullptr); + ~WaydroidApplicationListModel() override; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + Q_INVOKABLE void installApk(const QString apkFile); + Q_INVOKABLE void deleteApplication(const QString appId); + +Q_SIGNALS: + void actionFinished(const QString message); + void errorOccurred(const QString message); + +private: + WaydroidState *m_waydroidState{nullptr}; + QList m_applications; + QTimer *m_refreshTimer{nullptr}; + + void refreshApplications(); + QList queryApplications() const; +}; \ No newline at end of file diff --git a/components/waydroidintegrationplugin/waydroidshared.h b/components/waydroidintegrationplugin/waydroidshared.h new file mode 100644 index 00000000..b0d48d57 --- /dev/null +++ b/components/waydroidintegrationplugin/waydroidshared.h @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2025 Florian RICHER + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#define WAYDROID_COMMAND "waydroid" diff --git a/components/waydroidintegrationplugin/waydroidstate.cpp b/components/waydroidintegrationplugin/waydroidstate.cpp index 7fcd9416..371094ef 100644 --- a/components/waydroidintegrationplugin/waydroidstate.cpp +++ b/components/waydroidintegrationplugin/waydroidstate.cpp @@ -6,6 +6,7 @@ #include "waydroidstate.h" #include "waydroidintegrationplugin_debug.h" +#include "waydroidshared.h" #include #include @@ -22,7 +23,6 @@ using namespace Qt::StringLiterals; -#define WAYDROID_COMMAND "waydroid" #define MULTI_WINDOWS_PROP_KEY "persist.waydroid.multi_windows" #define SUSPEND_PROP_KEY "persist.waydroid.suspend" #define UEVENT_PROP_KEY "persist.waydroid.uevent" @@ -33,6 +33,7 @@ static const QRegularExpression systemOtaRegExp(u"system_ota\\s*=\\s*(\\S+)"_s); WaydroidState::WaydroidState(QObject *parent) : QObject{parent} + , m_applicationListModel{new WaydroidApplicationListModel{this}} { // Connect it-self to auto-refresh when required status has changed connect(this, &WaydroidState::statusChanged, this, &WaydroidState::refreshSessionInfo); @@ -335,6 +336,11 @@ QString WaydroidState::androidId() const return m_androidId; } +WaydroidApplicationListModel *WaydroidState::applicationListModel() const +{ + return m_applicationListModel; +} + bool WaydroidState::multiWindows() const { return m_multiWindows; diff --git a/components/waydroidintegrationplugin/waydroidstate.h b/components/waydroidintegrationplugin/waydroidstate.h index 98d6a8d1..99eb2ad6 100644 --- a/components/waydroidintegrationplugin/waydroidstate.h +++ b/components/waydroidintegrationplugin/waydroidstate.h @@ -6,11 +6,15 @@ #pragma once +#include "waydroidapplicationlistmodel.h" + #include #include #include +class WaydroidApplicationListModel; + /** * This class provides an interface to interact with the Waydroid container, * including session management and property configuration. @@ -33,6 +37,7 @@ class WaydroidState : public QObject Q_PROPERTY(bool multiWindows READ multiWindows WRITE setMultiWindows NOTIFY multiWindowsChanged) Q_PROPERTY(bool suspend READ suspend WRITE setSuspend NOTIFY suspendChanged) Q_PROPERTY(bool uevent READ uevent WRITE setUevent NOTIFY ueventChanged) + Q_PROPERTY(WaydroidApplicationListModel *applicationListModel READ applicationListModel CONSTANT) public: WaydroidState(QObject *parent = nullptr); @@ -102,6 +107,8 @@ public: QString androidId() const; QString errorTitle() const; QString errorMessage() const; + WaydroidApplicationListModel *applicationListModel() const; + bool multiWindows() const; void setMultiWindows(const bool multiWindows); bool suspend() const; @@ -129,6 +136,7 @@ private: QString m_errorTitle{""}; QString m_errorMessage{""}; QString m_androidId{""}; + WaydroidApplicationListModel *m_applicationListModel{nullptr}; // Waydroid props. See https://docs.waydro.id/usage/waydroid-prop-options bool m_multiWindows{false}; diff --git a/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml b/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml new file mode 100644 index 00000000..c8c9252b --- /dev/null +++ b/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2025 Florian RICHER + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import QtQuick.Dialogs + +import org.kde.kirigami 2.19 as Kirigami +import org.kde.kcmutils as KCM +import org.kde.kirigamiaddons.formcard 1.0 as FormCard +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP + +KCM.SimpleKCM { + id: root + + topPadding: Kirigami.Units.largeSpacing + bottomPadding: Kirigami.Units.largeSpacing + leftPadding: 0 + rightPadding: 0 + + title: i18n("Waydroid applications") + + actions: [ + Kirigami.Action { + text: i18nc("@action:button", "Install APK") + icon.name: "list-add" + + onTriggered: fileDialog.open() + } + ] + + Connections { + target: AIP.WaydroidState.applicationListModel + + function onActionFinished(message: string): void { + inlineMessage.text = message + inlineMessage.visible = true + inlineMessage.type = Kirigami.MessageType.Positive + } + + function onErrorOccurred(error: string): void { + inlineMessage.text = error + inlineMessage.visible = true + inlineMessage.type = Kirigami.MessageType.Error + } + } + + FileDialog { + id: fileDialog + nameFilters: [ "APK files (*.apk)" ] + + onAccepted: { + const url = new URL(selectedFile) + if (url.protocol !== "file:") { + inlineMessage.text = i18n("You must selected local file") + inlineMessage.visible = true + inlineMessage.type = Kirigami.MessageType.Error + } else { + AIP.WaydroidState.applicationListModel.installApk(url.pathname) + } + } + } + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + Kirigami.InlineMessage { + id: inlineMessage + + Layout.fillWidth: true + + visible: false + showCloseButton: true + } + + FormCard.FormCard { + Repeater { + model: AIP.WaydroidState.applicationListModel + + delegate: FormCard.AbstractFormDelegate { + id: appDelegate + + width: ListView.view.width + + background: null + contentItem: RowLayout { + QQC2.Label { + Layout.fillWidth: true + text: model.name + elide: Text.ElideRight + } + + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + text: i18nc("@action:button", "Delete the application") + icon.name: "usermenu-delete" + + onClicked: AIP.WaydroidState.applicationListModel.deleteApplication(model.id) + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + } + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + } + } + } +} diff --git a/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml b/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml index 2e2f1ba8..101dde99 100644 --- a/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml +++ b/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml @@ -42,11 +42,15 @@ ColumnLayout { } FormCard.FormButtonDelegate { - id: quickSettingsButton visible: AIP.WaydroidState.systemType === AIP.WaydroidState.Gapps text: i18n("Certify my device for Google Play Protect") onClicked: kcm.push("WaydroidGooglePlayProtectConfigurationPage.qml") } + + FormCard.FormButtonDelegate { + text: i18n("Installed applications") + onClicked: kcm.push("WaydroidApplicationsPage.qml") + } } // Some informations as IP address can take time to be set by Waydroid