kcm: Implement Waydroid Applications

This commit is contained in:
Florian RICHER 2025-07-14 12:47:21 +02:00
parent 93912960d7
commit de7b63572f
10 changed files with 503 additions and 4 deletions

View file

@ -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)
add_subdirectory(kauth)

View file

@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2025 Florian RICHER <florian.richer@protonmail.com>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#include <waydroidapplication.h>
#include <QRegularExpression>
#include <QStringLiteral>
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<WaydroidApplication>();
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;
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2025 Florian RICHER <florian.richer@protonmail.com>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#pragma once
#include <QDir>
#include <QObject>
#include <qtmetamacros.h>
class WaydroidApplication : public QObject, public std::enable_shared_from_this<WaydroidApplication>
{
Q_OBJECT
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(QString packageName READ packageName CONSTANT)
public:
typedef std::shared_ptr<WaydroidApplication> 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;
};

View file

@ -0,0 +1,198 @@
/*
* SPDX-FileCopyrightText: 2025 Florian RICHER <florian.richer@protonmail.com>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "waydroidapplicationlistmodel.h"
#include "waydroidintegrationplugin_debug.h"
#include "waydroidshared.h"
#include <QLoggingCategory>
#include <QProcess>
#include <QStringLiteral>
#include <KLocalizedString>
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<QString, int> appIdMap; // <packageName, index>
for (int i = 0; i < m_applications.size(); ++i) {
const auto &application = m_applications[i];
appIdMap.insert(application->packageName(), i);
}
QList<WaydroidApplication::Ptr> currentApps = queryApplications();
QList<WaydroidApplication::Ptr> 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<int> 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<WaydroidApplication::Ptr> WaydroidApplicationListModel::queryApplications() const
{
QList<WaydroidApplication::Ptr> 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<int, QByteArray> 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;
}
});
}

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2025 Florian RICHER <florian.richer@protonmail.com>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#pragma once
#include "waydroidapplication.h"
#include "waydroidstate.h"
#include <QAbstractListModel>
#include <QObject>
#include <QTimer>
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<int, QByteArray> 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<WaydroidApplication::Ptr> m_applications;
QTimer *m_refreshTimer{nullptr};
void refreshApplications();
QList<WaydroidApplication::Ptr> queryApplications() const;
};

View file

@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2025 Florian RICHER <florian.richer@protonmail.com>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#pragma once
#define WAYDROID_COMMAND "waydroid"

View file

@ -6,6 +6,7 @@
#include "waydroidstate.h"
#include "waydroidintegrationplugin_debug.h"
#include "waydroidshared.h"
#include <QClipboard>
#include <QDebug>
@ -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;

View file

@ -6,11 +6,15 @@
#pragma once
#include "waydroidapplicationlistmodel.h"
#include <QObject>
#include <qqmlregistration.h>
#include <qtmetamacros.h>
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};

View file

@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: 2025 Florian RICHER <florian.richer@protonmail.com>
* 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
}
}
}
}

View file

@ -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