diff --git a/androidconnect/Assets/brand-badges/android.svg b/androidconnect/Assets/brand-badges/android.svg
new file mode 100644
index 000000000..d36302515
--- /dev/null
+++ b/androidconnect/Assets/brand-badges/android.svg
@@ -0,0 +1,11 @@
+
diff --git a/androidconnect/Assets/brand-badges/google.svg b/androidconnect/Assets/brand-badges/google.svg
new file mode 100644
index 000000000..0d38ca3a2
--- /dev/null
+++ b/androidconnect/Assets/brand-badges/google.svg
@@ -0,0 +1,10 @@
+
diff --git a/androidconnect/Assets/brand-badges/xiaomi.svg b/androidconnect/Assets/brand-badges/xiaomi.svg
new file mode 100644
index 000000000..28037003f
--- /dev/null
+++ b/androidconnect/Assets/brand-badges/xiaomi.svg
@@ -0,0 +1,7 @@
+
diff --git a/androidconnect/BarWidget.qml b/androidconnect/BarWidget.qml
new file mode 100644
index 000000000..ac0f13108
--- /dev/null
+++ b/androidconnect/BarWidget.qml
@@ -0,0 +1,97 @@
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Modules.Bar.Extras
+import qs.Modules.Panels.Settings
+import qs.Services.UI
+import qs.Widgets
+import "./Services"
+
+Item {
+ id: root
+
+ property var pluginApi: null
+
+ property ShellScreen screen
+
+ // Widget properties passed from Bar.qml for per-instance settings
+ property string widgetId: ""
+ property string section: ""
+ property int sectionWidgetIndex: -1
+ property int sectionWidgetsCount: 0
+
+ // Explicit screenName property ensures reactive binding when screen changes
+ readonly property string screenName: screen ? screen.name : ""
+
+ implicitWidth: pill.width
+ implicitHeight: pill.height
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+
+ property bool hideIfNoDeviceConnected: cfg.hideIfNoDeviceConnected ?? defaults.hideIfNoDeviceConnected ?? false
+ property string iconColorKey: cfg.iconColor ?? defaults.iconColor ?? "none"
+ readonly property string wirelessAdbConnectHost: cfg.wirelessAdbConnectHost ?? defaults.wirelessAdbConnectHost ?? ""
+ readonly property string activeWirelessAdbSerial: {
+ const host = String(wirelessAdbConnectHost || "").trim();
+ if (host === "")
+ return "";
+
+ return KDEConnect.adbConnectedSerialForHost(host);
+ }
+ readonly property string transportIcon: {
+ if (!KDEConnect.daemonAvailable || KDEConnect.mainDevice === null || !KDEConnect.mainDevice.reachable)
+ return "device-mobile-off";
+
+ if (KDEConnect.adbHasUsbTransport)
+ return "device-mobile-bolt";
+
+ if (activeWirelessAdbSerial !== "")
+ return "device-mobile";
+
+ return "device-mobile";
+ }
+
+ visible: !hideIfNoDeviceConnected ? true : KDEConnect.anyDevicesConnected;
+ opacity: (!hideIfNoDeviceConnected ? true : KDEConnect.anyDevicesConnected) ? 1.0 : 0.0;
+
+ NPopupContextMenu {
+ id: contextMenu
+
+ model: [
+ {
+ "label": I18n.tr("actions.widget-settings"),
+ "action": "settings",
+ "icon": "settings"
+ }
+ ]
+
+ onTriggered: action => {
+ contextMenu.close();
+ PanelService.closeContextMenu(root.screen);
+
+ if (action === "settings" && pluginApi?.manifest) {
+ BarService.openPluginSettings(root.screen, pluginApi.manifest);
+ }
+ }
+ }
+
+ BarPill {
+ id: pill
+
+ screen: root.screen
+ oppositeDirection: BarService.getPillDirection(root)
+ customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
+ icon: root.transportIcon
+ autoHide: false // Important to be false so we can hover as long as we want
+ text: !KDEConnect.daemonAvailable || KDEConnect.mainDevice === null || KDEConnect.mainDevice.battery === -1 ? "" : (KDEConnect.mainDevice.battery + "%")
+ tooltipText: pluginApi?.tr("bar.tooltip")
+ onClicked: {
+ if (pluginApi) {
+ pluginApi.openPanel(root.screen);
+ }
+ }
+ onRightClicked: {
+ PanelService.showContextMenu(contextMenu, root, root.screen);
+ }
+ }
+}
diff --git a/androidconnect/Celu.png b/androidconnect/Celu.png
new file mode 100644
index 000000000..deba188fe
Binary files /dev/null and b/androidconnect/Celu.png differ
diff --git a/androidconnect/ControlCenterWidget.qml b/androidconnect/ControlCenterWidget.qml
new file mode 100644
index 000000000..84da1d6a8
--- /dev/null
+++ b/androidconnect/ControlCenterWidget.qml
@@ -0,0 +1,27 @@
+import QtQuick
+import Quickshell
+import qs.Widgets
+import "./Services"
+
+NIconButtonHot {
+ property ShellScreen screen
+ property var pluginApi: null
+
+ function getTooltip(device) {
+ const batteryLabel = pluginApi?.tr("panel.card.battery");
+ const stateLabel = pluginApi?.tr("control_center.state-label");
+
+ const batteryLine = (device !== null && device.reachable && device.paired && device.battery !== -1) ? (batteryLabel + ": " + device.battery + "%\n") : "";
+
+ const stateKey = KDEConnectUtils.getConnectionStateKey(device, KDEConnect.daemonAvailable);
+ const stateValue = pluginApi?.tr(stateKey);
+ const stateLine = stateLabel + ": " + stateValue;
+
+ return batteryLine + stateLine;
+ }
+
+ icon: KDEConnectUtils.getConnectionStateIcon(KDEConnect.mainDevice, KDEConnect.daemonAvailable)
+ tooltipText: getTooltip(KDEConnect.mainDevice)
+
+ onClicked: pluginApi?.togglePanel(screen, this)
+}
diff --git a/androidconnect/Docs/Screenshots/androidconnect-lock-screen.png b/androidconnect/Docs/Screenshots/androidconnect-lock-screen.png
new file mode 100644
index 000000000..2e979179e
Binary files /dev/null and b/androidconnect/Docs/Screenshots/androidconnect-lock-screen.png differ
diff --git a/androidconnect/Docs/Screenshots/androidconnect-panel-closeup.png b/androidconnect/Docs/Screenshots/androidconnect-panel-closeup.png
new file mode 100644
index 000000000..edc70aa17
Binary files /dev/null and b/androidconnect/Docs/Screenshots/androidconnect-panel-closeup.png differ
diff --git a/androidconnect/Docs/Screenshots/androidconnect-panel-overview.png b/androidconnect/Docs/Screenshots/androidconnect-panel-overview.png
new file mode 100644
index 000000000..cdb598c99
Binary files /dev/null and b/androidconnect/Docs/Screenshots/androidconnect-panel-overview.png differ
diff --git a/androidconnect/LICENSE b/androidconnect/LICENSE
new file mode 100644
index 000000000..d159169d1
--- /dev/null
+++ b/androidconnect/LICENSE
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ , 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/androidconnect/Main.qml b/androidconnect/Main.qml
new file mode 100644
index 000000000..20584b536
--- /dev/null
+++ b/androidconnect/Main.qml
@@ -0,0 +1,22 @@
+import QtQuick
+import Quickshell.Io
+import "./Services"
+
+Item {
+ property var pluginApi: null
+
+ onPluginApiChanged: {
+ KDEConnect.setMainDevice(pluginApi?.pluginSettings?.mainDeviceId || "")
+ }
+
+ IpcHandler {
+ target: "plugin:androidconnect"
+ function toggle() {
+ if (pluginApi) {
+ pluginApi.withCurrentScreen(screen => {
+ pluginApi.openPanel(screen);
+ });
+ }
+ }
+ }
+}
diff --git a/androidconnect/Panel.qml b/androidconnect/Panel.qml
new file mode 100644
index 000000000..1d715d82d
--- /dev/null
+++ b/androidconnect/Panel.qml
@@ -0,0 +1,3609 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+import "./Services"
+import Quickshell
+
+// Panel Component
+Item {
+ id: root
+
+ // Plugin API (injected by PluginPanelSlot)
+ property var pluginApi: null
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+
+ // SmartPanel
+ readonly property var geometryPlaceholder: panelContainer
+ readonly property bool panelAnchorTop: true
+ readonly property bool panelAnchorRight: true
+
+ property real contentPreferredWidth: phoneSizeValue(560, 620, 680) * Style.uiScaleRatio
+ property real contentPreferredHeight: deviceData.implicitHeight + (Style.marginM * 2)
+
+ readonly property bool allowAttach: true
+ readonly property color panelBackgroundColor: Color.mSurface
+ readonly property color shellPrimaryTextColor: Color.mOnSurface
+ readonly property color shellSecondaryTextColor: Color.mOnSurfaceVariant
+ readonly property color shellPrimaryIconColor: Color.mPrimary
+ readonly property color shellButtonBgColor: Color.mSurfaceVariant
+ readonly property color shellButtonFgColor: Color.mPrimary
+ readonly property color shellButtonBgHoverColor: Color.mHover
+ readonly property color shellButtonFgHoverColor: Color.mOnHover
+ readonly property color shellButtonBorderColor: Style.boxBorderColor
+ readonly property color shellButtonBorderHoverColor: Color.mOutline
+ readonly property color shellButtonActiveBgColor: Color.mPrimary
+ readonly property color shellButtonActiveFgColor: Color.mOnPrimary
+ readonly property color shellButtonActiveBorderColor: Color.mPrimary
+ readonly property color shellIconChipColor: Qt.alpha(Color.mPrimaryContainer, 0.8)
+ readonly property color shellIconChipBorderColor: Qt.alpha(Color.mPrimary, 0.42)
+ readonly property color shellIconChipFgColor: Color.mOnPrimaryContainer
+ readonly property color shellStageColor: Qt.alpha(Color.mSurface, 0.94)
+ readonly property color shellCardColor: Qt.alpha(Color.mSurfaceVariant, 0.84)
+ readonly property color shellCardBorderColor: Style.boxBorderColor
+ readonly property color shellNestedCardColor: Qt.alpha(Color.mSurface, 0.9)
+ readonly property color shellNestedCardBorderColor: Qt.alpha(Color.mOutline, 0.56)
+ readonly property color shellAccentCardColor: Qt.alpha(Color.mPrimaryContainer, 0.88)
+ readonly property color shellAccentCardBorderColor: Qt.alpha(Color.mPrimary, 0.42)
+ readonly property color shellAccentIconColor: Color.mOnPrimaryContainer
+ readonly property color shellAccentTextColor: Color.mOnPrimaryContainer
+ readonly property url androidBrandBadgeSource: Qt.resolvedUrl("./Assets/brand-badges/android.svg")
+ readonly property url googleBrandBadgeSource: Qt.resolvedUrl("./Assets/brand-badges/google.svg")
+ readonly property url xiaomiBrandBadgeSource: Qt.resolvedUrl("./Assets/brand-badges/xiaomi.svg")
+ readonly property bool blurEnabled: true
+ readonly property string embeddedMirrorCommand: "scrcpy --no-audio --capture-orientation=@0 --max-size=960 --max-fps=60 --video-bit-rate=12M --video-codec=h264 --v4l2-buffer=0"
+ readonly property bool reduceBackgroundRefreshWhileMirroring: true
+ readonly property string embeddedVideoDevice: "/dev/video10"
+ property string wirelessAdbPairHost: cfg.wirelessAdbPairHost ?? defaults.wirelessAdbPairHost ?? ""
+ property string wirelessAdbPairPort: cfg.wirelessAdbPairPort ?? defaults.wirelessAdbPairPort ?? ""
+ property string wirelessAdbPairingCode: ""
+ property string wirelessAdbConnectHost: cfg.wirelessAdbConnectHost ?? defaults.wirelessAdbConnectHost ?? ""
+ property string wirelessAdbConnectPort: cfg.wirelessAdbConnectPort ?? defaults.wirelessAdbConnectPort ?? ""
+ property string wirelessAdbStatusMessage: ""
+ property string wirelessAdbQrInstanceName: ""
+ property string wirelessAdbQrSecret: ""
+ property bool wirelessAdbQrPendingLaunch: false
+ property int wirelessAdbQrImageVersion: 0
+ property bool wirelessAdbSessionPreferred: false
+ property bool lastKnownUsbTransport: false
+ property var cachedDeviceTelemetry: initialCachedDeviceTelemetry()
+ readonly property string tempInstanceToken: makeTempInstanceToken()
+ readonly property string wirelessAdbQrImagePath: "/tmp/androidconnect-wireless-adb-" + tempInstanceToken + ".png"
+ readonly property string embeddedMirrorLoopbackSetupCommand: "sudo modprobe -r v4l2loopback 2>/dev/null || true\nsudo modprobe v4l2loopback devices=1 video_nr=10 card_label=scrcpy-panel exclusive_caps=0 max_width=960 max_height=2160"
+ readonly property real phoneBaseHeight: 732 * Style.uiScaleRatio
+ readonly property real phoneBaseWidth: phoneBaseHeight * (597 / 1241)
+ property int phoneSizePresetIndex: initialPhoneSizePresetIndex()
+ readonly property real phoneSizeFactor: phoneSizeValue(0.60, 0.75, 1.0)
+ readonly property int phoneSizePercent: phoneSizeValue(60, 75, 100)
+ readonly property string phoneSizeLabel: phoneSizeValue("Small", "Med", "Large")
+ readonly property real navButtonScaleFactor: phoneSizeValue(0.82, 0.91, 1.0)
+ readonly property var panelResizeBezierCurve: [0.05, 0, 0.133, 0.06, 0.166, 0.4, 0.208, 0.82, 0.25, 1, 1, 1]
+ property bool phoneSizeAnimationEnabled: false
+ property int phoneSizeStepDirection: initialPhoneSizeStepDirection()
+
+ property bool deviceSwitcherOpen: false
+ property var activePhonePreview: null
+ property bool embeddedVideoDeviceAccessible: false
+ property bool embeddedVideoDeviceCheckKnown: false
+ property double embeddedVideoDeviceLastCheckAtMs: 0
+ property bool embeddedMirrorAudioEnabled: Boolean(
+ cfg.embeddedMirrorAudioEnabled
+ ?? defaults.embeddedMirrorAudioEnabled
+ ?? false
+ )
+ property double panelVisibleSinceMs: 0
+ property bool panelStatusGraceElapsed: true
+ property bool panelOpenUnlockPending: false
+ property int panelOpenUnlockRetriesRemaining: 0
+ readonly property int panelStatusGraceMs: 5000
+ property bool keepScreenOnPending: false
+ property bool keepScreenOnEnabled: false
+ property string keepScreenOnSerial: ""
+ property string keepScreenOnOriginalTimeout: ""
+ readonly property int keepScreenOnTimeoutMs: 2147483647
+ property bool dimScreenPending: false
+ property bool dimScreenEnabled: false
+ property string dimScreenSerial: ""
+ property string dimScreenOriginalBrightness: ""
+ property string dimScreenOriginalMode: ""
+ readonly property int dimScreenBrightnessValue: 0
+ property int embeddedMirrorFormatLockRetryCount: 0
+ readonly property int embeddedMirrorWarmStopTimeoutMs: 120000
+
+ anchors.fill: parent
+
+ Timer {
+ id: embeddedMirrorFeedWatchdog
+ interval: 700
+ repeat: true
+ running: root.visible && root.embeddedMirrorFeedConfigured()
+ onTriggered: {
+ root.ensureEmbeddedVideoDeviceAccessFresh(root.embeddedVideoDeviceAccessible ? 1800 : 900);
+ }
+ }
+
+ Timer {
+ id: embeddedMirrorAutoStartTimer
+ interval: 60
+ repeat: false
+ onTriggered: {
+ root.attemptEmbeddedMirrorAutoStart();
+ }
+ }
+
+ Timer {
+ id: embeddedMirrorFormatLockTimer
+ interval: 100
+ repeat: false
+ onTriggered: {
+ if (!root.visible
+ || !root.embeddedMirrorFeedConfigured()
+ || !KDEConnect.scrcpyRunning
+ || embeddedMirrorFormatLockProc.running) {
+ return;
+ }
+
+ embeddedMirrorFormatLockProc.running = true;
+ }
+ }
+
+ Timer {
+ id: embeddedMirrorFormatLockRetryTimer
+ interval: 260
+ repeat: false
+ onTriggered: {
+ if (!root.visible
+ || !root.embeddedMirrorFeedConfigured()
+ || !KDEConnect.scrcpyRunning
+ || embeddedMirrorFormatLockProc.running
+ || !root.activePhonePreview
+ || !root.activePhonePreview.mirrorFeedEnabled) {
+ return;
+ }
+
+ embeddedMirrorFormatLockProc.running = true;
+ }
+ }
+
+ Timer {
+ id: embeddedMirrorWarmStopTimer
+ interval: root.embeddedMirrorWarmStopTimeoutMs
+ repeat: false
+ onTriggered: {
+ if (root.visible)
+ return;
+
+ KDEConnect.forceStopScrcpyProcesses(root.embeddedVideoDevice);
+ }
+ }
+
+ Timer {
+ id: panelOpenUnlockTimer
+ interval: 240
+ repeat: false
+ onTriggered: {
+ if (!root.panelOpenUnlockPending || !root.visible || !root.embeddedMirrorModeEnabled()) {
+ root.clearPanelOpenUnlockState();
+ return;
+ }
+
+ if (!KDEConnect.scrcpyRunning || KDEConnect.scrcpyLaunching) {
+ root.retryPanelOpenUnlock();
+ return;
+ }
+
+ if (!root.embeddedMirrorTouchActive()) {
+ root.scheduleTouchMappingRefresh();
+ root.retryPanelOpenUnlock();
+ return;
+ }
+
+ const serial = root.currentMirrorAdbSerial();
+ if (serial === "") {
+ root.clearPanelOpenUnlockState();
+ return;
+ }
+
+ if (!KDEConnect.hasFreshAdbScreenState(serial)) {
+ KDEConnect.queryAdbScreenState(serial);
+ root.retryPanelOpenUnlock();
+ return;
+ }
+
+ root.clearPanelOpenUnlockState();
+ if (!KDEConnect.adbUnlockNeeded)
+ return;
+
+ root.sendAndroidUnlockOnly();
+ }
+ }
+
+ Timer {
+ id: panelStatusGraceTimer
+ interval: root.panelStatusGraceMs
+ repeat: false
+ onTriggered: {
+ root.panelStatusGraceElapsed = true;
+ }
+ }
+
+ Timer {
+ id: adbDevicesRefreshTimer
+ interval: 2500
+ repeat: true
+ running: root.visible
+ onTriggered: {
+ KDEConnect.refreshAdbDevices();
+ }
+ }
+
+ Component.onCompleted: {
+ if (pluginApi) {
+ Logger.i("KDEConnect", "Panel initialized");
+ }
+ root.syncBackgroundRefreshPolicy();
+ KDEConnect.refreshAdbDevices();
+ Qt.callLater(function() {
+ root.refreshEmbeddedVideoDeviceAccess();
+ });
+ }
+
+ onEmbeddedVideoDeviceChanged: {
+ resetEmbeddedVideoDeviceAccess(false);
+ Qt.callLater(function() {
+ root.refreshEmbeddedVideoDeviceAccess();
+ });
+ }
+
+ Connections {
+ target: KDEConnect
+
+ function onScrcpyRunningChanged() {
+ root.syncBackgroundRefreshPolicy();
+ if (root.visible && root.panelOpenUnlockPending && KDEConnect.scrcpyRunning)
+ panelOpenUnlockTimer.restart();
+
+ if (root.visible && root.panelOpenUnlockPending && KDEConnect.scrcpyRunning)
+ root.refreshPanelOpenUnlockState();
+
+ if (root.embeddedMirrorModeEnabled() && KDEConnect.scrcpyRunning && root.activePhonePreview) {
+ root.scheduleTouchMappingRefresh();
+ }
+
+ if (root.visible && KDEConnect.scrcpyRunning)
+ embeddedMirrorFormatLockTimer.restart();
+
+ if (root.visible && !KDEConnect.scrcpyRunning && !KDEConnect.scrcpyLaunching)
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+
+ function onAdbDevicesRefreshed() {
+ const usbTransportLost = root.lastKnownUsbTransport && !KDEConnect.adbHasUsbTransport;
+ root.lastKnownUsbTransport = KDEConnect.adbHasUsbTransport;
+
+ if (KDEConnect.adbHasUsbTransport)
+ root.wirelessAdbSessionPreferred = false;
+
+ if (usbTransportLost
+ && root.embeddedMirrorModeEnabled()
+ && KDEConnect.scrcpyRunning
+ && KDEConnect.isUsbSelectionSerial(KDEConnect.scrcpyActiveSerial)) {
+ Logger.w("KDEConnect", "USB transport lost, stopping embedded feed session");
+ KDEConnect.stopScrcpySession();
+ }
+
+ if (root.embeddedMirrorModeEnabled() && KDEConnect.scrcpyRunning) {
+ root.scheduleTouchMappingRefresh();
+ }
+
+ if (root.visible && root.panelOpenUnlockPending && KDEConnect.scrcpyRunning)
+ root.refreshPanelOpenUnlockState();
+
+ if (!KDEConnect.scrcpyRunning && !KDEConnect.scrcpyLaunching)
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+
+ function onDevicesChanged() {
+ const devices = KDEConnect.devices || [];
+ for (let i = 0; i < devices.length; ++i)
+ root.updateCachedTelemetryForDevice(devices[i]);
+
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+
+ function onMainDeviceChanged() {
+ if (KDEConnect.mainDevice)
+ root.updateCachedTelemetryForDevice(KDEConnect.mainDevice);
+
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+
+ function onScrcpyLaunchErrorChanged() {
+ if (!root.embeddedMirrorFeedConfigured()
+ || KDEConnect.scrcpyLaunching
+ || KDEConnect.scrcpyRunning
+ || KDEConnect.scrcpyLaunchError === "")
+ return;
+
+ const errorText = String(KDEConnect.scrcpyLaunchError);
+ const isFeedFailure = errorText.indexOf("V4L2 sink") !== -1
+ || errorText.indexOf("/dev/video") !== -1
+ || errorText.indexOf("Failed to open output") !== -1
+ || errorText.indexOf("Failed to write header") !== -1
+ || errorText.indexOf("Demuxer") !== -1;
+
+ if (!isFeedFailure)
+ return;
+
+ root.resetEmbeddedVideoDeviceAccess(false);
+ Qt.callLater(function() {
+ root.refreshEmbeddedVideoDeviceAccess();
+ });
+ Logger.w("KDEConnect", "Embedded feed failed:", errorText);
+ }
+
+ function onWirelessAdbFinished(success, message) {
+ if (success) {
+ const usedQrFlow = root.applyWirelessAdbQrSuccess(message);
+ root.wirelessAdbSessionPreferred = true;
+ KDEConnect.refreshAdbDevices();
+ const body = usedQrFlow
+ ? root.trSafe("panel.wireless-adb.qr-success-description", "Wireless ADB paired and connected from the QR code.")
+ : (message && message !== "ok"
+ ? message
+ : root.trSafe("panel.wireless-adb.success-description", "ADB over TCP/IP enabled"));
+ root.wirelessAdbStatusMessage = body;
+ KDEConnect.showNoticeWithHistory(root.trSafe("panel.wireless-adb.success-title", "Wireless ADB"), body, "wifi");
+ root.scheduleTouchMappingRefresh();
+ } else {
+ const body = message === "missing_command"
+ ? root.trSafe("panel.wireless-adb.missing-command-description", "Wireless ADB could not start the built-in adb tcpip helper.")
+ : message === "missing_pair_parameters"
+ ? root.trSafe("panel.wireless-adb.missing-pair-parameters-description", "Enter the phone IP, pairing port, and pairing code")
+ : message === "missing_connect_parameters"
+ ? root.trSafe("panel.wireless-adb.missing-connect-parameters-description", "Enter the phone IP and connect port")
+ : message === "missing_qr_parameters"
+ ? root.trSafe("panel.wireless-adb.missing-qr-parameters-description", "Generate a fresh Wireless ADB QR code and try again.")
+ : message;
+ root.wirelessAdbStatusMessage = body;
+ KDEConnect.showWarningWithHistory(root.trSafe("panel.wireless-adb.error-title", "Wireless ADB"), body, 5000);
+ }
+ }
+
+ function onAdbScreenStateRefreshed(serial, unlockNeeded, interactive, lockState) {
+ if (!root.visible || !root.panelOpenUnlockPending)
+ return;
+
+ if (String(serial || "").trim() !== root.currentMirrorAdbSerial())
+ return;
+
+ panelOpenUnlockTimer.restart();
+ }
+
+ function onAdbScreenTimeoutRead(serial, value, success) {
+ if (!root.keepScreenOnPending)
+ return;
+
+ if (String(serial || "").trim() !== root.keepScreenOnSerial)
+ return;
+
+ root.keepScreenOnPending = false;
+ if (!success)
+ return;
+
+ root.keepScreenOnEnabled = true;
+ root.keepScreenOnOriginalTimeout = String(value || "").trim();
+ KDEConnect.setAdbScreenTimeout(root.keepScreenOnSerial, String(root.keepScreenOnTimeoutMs));
+ }
+
+ function onAdbScreenBrightnessRead(serial, mode, value, success) {
+ if (!root.dimScreenPending)
+ return;
+
+ if (String(serial || "").trim() !== root.dimScreenSerial)
+ return;
+
+ root.dimScreenPending = false;
+ if (!success)
+ return;
+
+ root.dimScreenEnabled = true;
+ root.dimScreenOriginalMode = String(mode || "").trim();
+ root.dimScreenOriginalBrightness = String(value || "").trim();
+ KDEConnect.setAdbScreenBrightness(root.dimScreenSerial, String(root.dimScreenBrightnessValue));
+ }
+ }
+
+ Component.onDestruction: {
+ root.restoreDimScreenState();
+ root.restoreKeepScreenOnState();
+ KDEConnect.reduceBackgroundRefresh = false;
+ embeddedMirrorAutoStartTimer.stop();
+ embeddedMirrorWarmStopTimer.stop();
+ panelOpenUnlockTimer.stop();
+ root.clearPanelOpenUnlockState();
+ KDEConnect.forceStopScrcpyProcesses(root.embeddedVideoDevice);
+ }
+
+ onVisibleChanged: {
+ root.syncBackgroundRefreshPolicy();
+ if (visible) {
+ embeddedMirrorWarmStopTimer.stop();
+ root.panelVisibleSinceMs = Date.now();
+ root.panelStatusGraceElapsed = false;
+ panelStatusGraceTimer.restart();
+ KDEConnect.refreshAdbDevices();
+ if (KDEConnect.daemonAvailable)
+ KDEConnect.refreshDevices();
+ root.refreshEmbeddedVideoDeviceAccess();
+ root.panelOpenUnlockPending = root.embeddedMirrorModeEnabled();
+ root.panelOpenUnlockRetriesRemaining = 12;
+ root.refreshPanelOpenUnlockState();
+ if (KDEConnect.scrcpyRunning)
+ panelOpenUnlockTimer.restart();
+ if (KDEConnect.scrcpyRunning)
+ embeddedMirrorFormatLockTimer.restart();
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+ if (!visible) {
+ root.restoreDimScreenState();
+ root.restoreKeepScreenOnState();
+ root.panelVisibleSinceMs = 0;
+ root.panelStatusGraceElapsed = true;
+ embeddedMirrorAutoStartTimer.stop();
+ embeddedMirrorFormatLockTimer.stop();
+ panelStatusGraceTimer.stop();
+ panelOpenUnlockTimer.stop();
+ root.clearPanelOpenUnlockState();
+ if (KDEConnect.scrcpyRunning || KDEConnect.scrcpyLaunching)
+ embeddedMirrorWarmStopTimer.restart();
+ }
+ }
+
+ onEmbeddedMirrorAudioEnabledChanged: root.persistEmbeddedMirrorAudioMode()
+
+ function mainDeviceSetupComplete() {
+ return KDEConnect.mainDevice !== null
+ && Boolean(KDEConnect.mainDevice.paired)
+ && Boolean(KDEConnect.mainDevice.reachable);
+ }
+
+ function mainDevicePairingInProgress() {
+ if (KDEConnect.mainDevice === null || KDEConnect.mainDevice.paired)
+ return false;
+
+ return Boolean(KDEConnect.mainDevice.pairRequested)
+ || String(KDEConnect.mainDevice.verificationKey || "").trim() !== "";
+ }
+
+ function handlePhoneClick(preview) {
+ if (KDEConnect.mainDevice === null || !root.mainDeviceSetupComplete())
+ return;
+
+ if (!KDEConnect.scrcpyRunning
+ && !KDEConnect.scrcpyLaunching
+ && !root.scrcpyLaunchPrerequisitesReady()) {
+ KDEConnect.refreshAdbDevices();
+ return;
+ }
+
+ ensureEmbeddedMirrorSession(preview);
+ }
+
+ function copyTextToClipboard(text, successMessage) {
+ const trimmedText = String(text || "").trim();
+ if (trimmedText === "")
+ return;
+
+ Quickshell.execDetached(["wl-copy", trimmedText]);
+ KDEConnect.showNoticeWithHistory(
+ root.trSafe("panel.setup-required.copy-title", "AndroidConnect"),
+ successMessage || root.trSafe("panel.setup-required.copy-success", "Copied to clipboard."),
+ "copy"
+ );
+ }
+
+ function triggerMainDevicePairing() {
+ if (KDEConnect.mainDevice === null || KDEConnect.mainDevice.paired)
+ return;
+
+ KDEConnect.requestPairing(KDEConnect.mainDevice.id);
+ KDEConnect.mainDevice.pairRequested = true;
+ KDEConnect.refreshDevices();
+ }
+
+ function setupRequiredPairingStepText() {
+ if (KDEConnect.mainDevice === null) {
+ return root.trSafe(
+ "panel.setup-required.step-1-discovery",
+ "1. Open KDE Connect on the phone and keep it on the same network so the desktop can discover it."
+ );
+ }
+
+ if (KDEConnect.mainDevice.paired && KDEConnect.mainDevice.reachable) {
+ return root.trSafe(
+ "panel.setup-required.step-1-ready",
+ "1. KDE Connect pairing is ready."
+ );
+ }
+
+ if (KDEConnect.mainDevice.paired) {
+ return root.trSafe(
+ "panel.setup-required.step-1-known-paired",
+ "1. KDE Connect knows about a paired phone entry, but the phone is not reachable yet."
+ );
+ }
+
+ return root.trSafe(
+ "panel.setup-required.step-1-pair",
+ "1. Start KDE Connect pairing here, then approve it on the phone."
+ );
+ }
+
+ function setupRequiredAdbStepText() {
+ if (KDEConnect.mainDevice === null || !KDEConnect.mainDevice.paired) {
+ return root.trSafe(
+ "panel.setup-required.step-2-after-pair",
+ "2. After pairing, enable USB debugging on the phone and authorize this computer once over USB."
+ );
+ }
+
+ if (!KDEConnect.mainDevice.reachable) {
+ return root.trSafe(
+ "panel.setup-required.step-2-reachable",
+ "2. Keep KDE Connect open on the phone and make sure both devices stay on the same network until the phone becomes reachable."
+ );
+ }
+
+ const adbIssueSubtitle = root.adbSetupIssueSubtitle();
+ if ((adbIssueSubtitle || "").trim() !== "")
+ return "2. " + adbIssueSubtitle;
+
+ return root.trSafe(
+ "panel.setup-required.step-2-ready",
+ "2. USB debugging is ready."
+ );
+ }
+
+ function setupRequiredLoopbackStepText() {
+ if (!root.embeddedVideoDeviceCheckKnown) {
+ return root.trSafe(
+ "panel.setup-required.step-3-checking",
+ "3. Checking the V4L2 loopback device for the embedded live feed."
+ );
+ }
+
+ if (root.embeddedVideoDeviceAccessible) {
+ return root.trSafe(
+ "panel.setup-required.step-3-ready",
+ "3. V4L2 loopback device detected: "
+ ) + root.embeddedVideoDevice;
+ }
+
+ return root.trSafe(
+ "panel.setup-required.step-3-missing",
+ "3. Create the V4L2 loopback device if you want the embedded live feed."
+ );
+ }
+
+ function setupRequiredLoopbackCommandVisible() {
+ return root.embeddedMirrorModeEnabled()
+ && root.embeddedMirrorFeedConfigured()
+ && root.embeddedVideoDeviceCheckKnown
+ && !root.embeddedVideoDeviceAccessible;
+ }
+
+ function embeddedMirrorRequiredFeedDeviceStatusText() {
+ if (!root.embeddedMirrorFeedConfigured()) {
+ return root.trSafe(
+ "panel.embedded-mirror.required-device-not-configured",
+ "Required V4L2 device is not configured."
+ );
+ }
+
+ if (!root.embeddedVideoDeviceCheckKnown) {
+ return root.trSafe(
+ "panel.embedded-mirror.required-device-checking",
+ "Checking required V4L2 device: "
+ ) + root.embeddedVideoDevice;
+ }
+
+ if (root.embeddedVideoDeviceAccessible) {
+ return root.trSafe(
+ "panel.embedded-mirror.required-device-found",
+ "Required V4L2 device found: "
+ ) + root.embeddedVideoDevice;
+ }
+
+ return root.trSafe(
+ "panel.embedded-mirror.required-device-missing",
+ "Required V4L2 device not found: "
+ ) + root.embeddedVideoDevice;
+ }
+
+ function scheduleEmbeddedMirrorAutoStart() {
+ if (!root.visible
+ || !embeddedMirrorModeEnabled()
+ || !root.mainDeviceSetupComplete()
+ || !root.scrcpyLaunchPrerequisitesReady()) {
+ return;
+ }
+
+ embeddedMirrorAutoStartTimer.restart();
+ }
+
+ function attemptEmbeddedMirrorAutoStart() {
+ if (!root.visible
+ || !embeddedMirrorModeEnabled()
+ || !root.mainDeviceSetupComplete()
+ || !root.activePhonePreview
+ || !root.scrcpyLaunchPrerequisitesReady()) {
+ return;
+ }
+
+ root.ensureEmbeddedMirrorSession(root.activePhonePreview);
+ }
+
+
+ function cyclePhoneSizePreset() {
+ phoneSizeAnimationEnabled = true;
+ if (phoneSizePresetIndex >= 2)
+ phoneSizeStepDirection = -1;
+ else if (phoneSizePresetIndex <= 0)
+ phoneSizeStepDirection = 1;
+
+ phoneSizePresetIndex = Math.max(0, Math.min(2, phoneSizePresetIndex + phoneSizeStepDirection));
+
+ if (phoneSizePresetIndex >= 2)
+ phoneSizeStepDirection = -1;
+ else if (phoneSizePresetIndex <= 0)
+ phoneSizeStepDirection = 1;
+
+ persistPhoneSizePreset();
+ }
+
+ function phoneSizeValue(small, medium, large) {
+ if (phoneSizePresetIndex === 0)
+ return small;
+ if (phoneSizePresetIndex === 1)
+ return medium;
+ return large;
+ }
+
+ function initialPhoneSizePresetIndex() {
+ const explicitKey = String(cfg.phoneSizePresetKey ?? defaults.phoneSizePresetKey ?? "").trim().toLowerCase();
+ if (explicitKey === "small")
+ return 0;
+ if (explicitKey === "medium")
+ return 1;
+ if (explicitKey === "large")
+ return 2;
+
+ const legacyIndex = Math.max(0, Math.min(2, Number(cfg.phoneSizePresetIndex ?? defaults.phoneSizePresetIndex ?? 0)));
+ if (legacyIndex === 2)
+ return 0;
+ if (legacyIndex === 1)
+ return 1;
+ return 2;
+ }
+
+ function currentPhoneSizePresetKey() {
+ return phoneSizeValue("small", "medium", "large");
+ }
+
+ function initialPhoneSizeStepDirection() {
+ const storedDirection = Number(cfg.phoneSizeStepDirection ?? defaults.phoneSizeStepDirection ?? 0);
+ if (storedDirection === -1 || storedDirection === 1)
+ return storedDirection;
+
+ return initialPhoneSizePresetIndex() >= 2 ? -1 : 1;
+ }
+
+ function persistPhoneSizePreset() {
+ if (!pluginApi)
+ return;
+
+ pluginApi.pluginSettings.phoneSizePresetKey = currentPhoneSizePresetKey();
+ pluginApi.pluginSettings.phoneSizePresetIndex = phoneSizePresetIndex;
+ pluginApi.pluginSettings.phoneSizeStepDirection = phoneSizeStepDirection;
+ pluginApi.saveSettings();
+ }
+
+ function persistEmbeddedMirrorAudioMode() {
+ if (!pluginApi)
+ return;
+
+ pluginApi.pluginSettings.embeddedMirrorAudioEnabled = embeddedMirrorAudioEnabled;
+ pluginApi.saveSettings();
+ }
+
+ function trSafe(key, fallback) {
+ const translated = pluginApi?.tr(key);
+ if (translated === undefined || translated === null)
+ return fallback;
+
+ const text = String(translated);
+ return (text === "" || text.startsWith("!!")) ? fallback : text;
+ }
+
+ function deviceBrandBadge(deviceName) {
+ const brandName = String(deviceName || "").trim().toLowerCase();
+ const isApple = brandName.indexOf("iphone") !== -1
+ || brandName.indexOf("ipad") !== -1
+ || brandName.indexOf("apple") !== -1;
+ const isGoogle = brandName.indexOf("pixel") !== -1
+ || brandName.indexOf("google") !== -1;
+ const isXiaomiFamily = brandName.indexOf("xiaomi") !== -1
+ || brandName.indexOf("redmi") !== -1
+ || brandName.indexOf("poco") !== -1;
+ const fallbackIcon = isApple
+ ? "brand-apple"
+ : (isGoogle ? "brand-google" : "brand-android");
+
+ if (isGoogle) {
+ return {
+ source: googleBrandBadgeSource,
+ fallbackIcon: fallbackIcon
+ };
+ }
+
+ if (isXiaomiFamily) {
+ return {
+ source: xiaomiBrandBadgeSource,
+ fallbackIcon: fallbackIcon
+ };
+ }
+
+ if (brandName.indexOf("android") !== -1) {
+ return {
+ source: androidBrandBadgeSource,
+ fallbackIcon: fallbackIcon
+ };
+ }
+
+ return {
+ source: "",
+ fallbackIcon: fallbackIcon
+ };
+ }
+
+ function adbDeviceStateEntries() {
+ const states = KDEConnect.adbDeviceStates || ({});
+ const entries = [];
+ for (const serial in states) {
+ if (!Object.prototype.hasOwnProperty.call(states, serial))
+ continue;
+
+ entries.push({
+ serial: String(serial || "").trim(),
+ state: String(states[serial] || "").trim()
+ });
+ }
+ return entries;
+ }
+
+ function adbSerialsInState(targetState) {
+ const desiredState = String(targetState || "").trim();
+ if (desiredState === "")
+ return [];
+
+ return adbDeviceStateEntries()
+ .filter(entry => entry.serial !== "" && entry.state === desiredState)
+ .map(entry => entry.serial);
+ }
+
+ function connectedWirelessAdbSerial() {
+ const configuredSerial = configuredWirelessAdbSerial();
+ if (configuredSerial !== "" && KDEConnect.adbDeviceSerialConnected(configuredSerial))
+ return configuredSerial;
+
+ return KDEConnect.adbConnectedSerialForHost((wirelessAdbConnectHost || "").trim());
+ }
+
+ function adbSetupIssueTitle() {
+ if (KDEConnect.scrcpyRunning
+ || KDEConnect.scrcpyLaunching
+ || !root.mainDeviceSetupComplete())
+ return "";
+
+ if (KDEConnect.adbDevicesExitCode !== 0)
+ return trSafe("panel.scrcpy.adb-missing-title", "adb Not Available");
+
+ if (adbSerialsInState("unauthorized").length > 0)
+ return trSafe("panel.scrcpy.adb-authorize-title", "Authorize USB Debugging");
+
+ if (adbSerialsInState("offline").length > 0)
+ return trSafe("panel.scrcpy.adb-offline-title", "Reconnect ADB");
+
+ if (!KDEConnect.adbHasUsbTransport && connectedWirelessAdbSerial() === "")
+ return trSafe("panel.scrcpy.adb-setup-title", "Connect ADB First");
+
+ return "";
+ }
+
+ function adbSetupIssueSubtitle() {
+ const issueTitle = adbSetupIssueTitle();
+ if (issueTitle === "")
+ return "";
+
+ if (KDEConnect.adbDevicesExitCode !== 0) {
+ const stderrText = String(KDEConnect.adbDevicesStderr || "").trim();
+ return stderrText !== ""
+ ? stderrText
+ : trSafe("panel.scrcpy.adb-missing-description", "Install Android platform-tools so the plugin can use adb for mirroring and input.");
+ }
+
+ if (adbSerialsInState("unauthorized").length > 0)
+ return trSafe("panel.scrcpy.adb-authorize-description", "Enable Developer options and USB debugging on the phone, connect it over USB, unlock it, and accept the USB debugging prompt for this computer.");
+
+ if (adbSerialsInState("offline").length > 0)
+ return trSafe("panel.scrcpy.adb-offline-description", "adb can see the phone, but it is not ready yet. Reconnect the cable, unlock the phone, and accept the USB debugging prompt again.");
+
+ if (!KDEConnect.adbHasUsbTransport && connectedWirelessAdbSerial() === "")
+ return trSafe("panel.scrcpy.adb-setup-wireless-description", "Enable Developer options and USB debugging on the phone, connect it over USB once and accept the debugging prompt, or pair Wireless ADB from the Wi-Fi button.");
+
+ return "";
+ }
+
+ function scrcpyLaunchPrerequisitesReady() {
+ if ((adbSetupIssueTitle() || "").trim() !== "")
+ return false;
+
+ if (embeddedMirrorModeEnabled()) {
+ if ((embeddedMirrorCommand || "").trim() === "")
+ return false;
+
+ if (embeddedMirrorFeedConfigured()) {
+ if (!embeddedVideoDeviceCheckKnown) {
+ if (!embeddedVideoDeviceCheckProc.running)
+ refreshEmbeddedVideoDeviceAccess();
+ return false;
+ }
+
+ if (!embeddedVideoDeviceAccessible)
+ return false;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ function initialCachedDeviceTelemetry() {
+ const rawValue = cfg.cachedDeviceTelemetry ?? defaults.cachedDeviceTelemetry ?? ({});
+ if (rawValue && typeof rawValue === "object")
+ return rawValue;
+
+ return ({});
+ }
+
+ function telemetryCacheKey(device) {
+ return String(device?.id || "").trim();
+ }
+
+ function cachedTelemetryForDevice(device) {
+ const key = telemetryCacheKey(device);
+ if (key === "")
+ return null;
+
+ const cache = cachedDeviceTelemetry || ({});
+ const entry = cache[key];
+ return (entry && typeof entry === "object") ? entry : null;
+ }
+
+ function persistCachedDeviceTelemetry() {
+ if (!pluginApi)
+ return;
+
+ pluginApi.pluginSettings.cachedDeviceTelemetry = cachedDeviceTelemetry;
+ pluginApi.saveSettings();
+ }
+
+ function updateCachedTelemetryForDevice(device) {
+ const key = telemetryCacheKey(device);
+ if (key === "")
+ return;
+
+ const current = device || ({});
+ const previous = cachedTelemetryForDevice(device) || ({});
+ const next = {
+ battery: Number(current.battery) >= 0 ? Number(current.battery) : previous.battery,
+ charging: Number(current.battery) >= 0 ? Boolean(current.charging) : previous.charging,
+ cellularNetworkType: String(current.cellularNetworkType || "").trim() !== ""
+ ? String(current.cellularNetworkType).trim()
+ : previous.cellularNetworkType,
+ cellularNetworkStrength: Number(current.cellularNetworkStrength) >= 0
+ ? Number(current.cellularNetworkStrength)
+ : previous.cellularNetworkStrength,
+ notificationCount: Array.isArray(current.notificationIds)
+ ? current.notificationIds.length
+ : previous.notificationCount
+ };
+
+ const changed = JSON.stringify(previous) !== JSON.stringify(next);
+ if (!changed)
+ return;
+
+ cachedDeviceTelemetry = Object.assign({}, cachedDeviceTelemetry || ({}), {
+ [key]: next
+ });
+ persistCachedDeviceTelemetry();
+ }
+
+ function effectiveBatteryValue(device) {
+ const battery = Number(device?.battery);
+ if (isFinite(battery) && battery >= 0)
+ return battery;
+
+ const cached = cachedTelemetryForDevice(device);
+ const cachedBattery = Number(cached?.battery);
+ return (isFinite(cachedBattery) && cachedBattery >= 0) ? cachedBattery : -1;
+ }
+
+ function effectiveChargingValue(device) {
+ const liveBattery = Number(device?.battery);
+ if (isFinite(liveBattery) && liveBattery >= 0)
+ return Boolean(device?.charging);
+
+ const cached = cachedTelemetryForDevice(device);
+ return Boolean(cached?.charging);
+ }
+
+ function effectiveNetworkType(device) {
+ const liveValue = String(device?.cellularNetworkType || "").trim();
+ if (liveValue !== "")
+ return liveValue;
+
+ const cached = cachedTelemetryForDevice(device);
+ return String(cached?.cellularNetworkType || "").trim();
+ }
+
+ function effectiveSignalStrength(device) {
+ const liveValue = Number(device?.cellularNetworkStrength);
+ if (isFinite(liveValue) && liveValue >= 0)
+ return liveValue;
+
+ const cached = cachedTelemetryForDevice(device);
+ const cachedValue = Number(cached?.cellularNetworkStrength);
+ return (isFinite(cachedValue) && cachedValue >= 0) ? cachedValue : -1;
+ }
+
+ function effectiveNotificationCount(device) {
+ if (Array.isArray(device?.notificationIds))
+ return device.notificationIds.length;
+
+ const cached = cachedTelemetryForDevice(device);
+ const cachedValue = Number(cached?.notificationCount);
+ return isFinite(cachedValue) && cachedValue >= 0 ? cachedValue : 0;
+ }
+
+ function randomTokenFromAlphabet(length, alphabet) {
+ const size = Math.max(1, Math.round(length || 1));
+ const source = String(alphabet || "0123456789");
+ let token = "";
+
+ for (let i = 0; i < size; ++i) {
+ token += source.charAt(Math.floor(Math.random() * source.length));
+ }
+
+ return token;
+ }
+
+ function makeTempInstanceToken() {
+ return Date.now().toString(36)
+ + "-"
+ + randomTokenFromAlphabet(8, "abcdefghijklmnopqrstuvwxyz0123456789");
+ }
+
+ function escapeWirelessAdbQrValue(value) {
+ return String(value || "").replace(/([\\;,:])/g, "\\$1");
+ }
+
+ function wirelessAdbQrPayload() {
+ if ((wirelessAdbQrInstanceName || "").trim() === "" || (wirelessAdbQrSecret || "").trim() === "")
+ return "";
+
+ return "WIFI:T:ADB;S:"
+ + escapeWirelessAdbQrValue(wirelessAdbQrInstanceName)
+ + ";P:"
+ + escapeWirelessAdbQrValue(wirelessAdbQrSecret)
+ + ";;";
+ }
+
+ function wirelessAdbQrImageSource() {
+ if (wirelessAdbQrImageVersion <= 0)
+ return "";
+
+ return "file://" + wirelessAdbQrImagePath + "?v=" + wirelessAdbQrImageVersion;
+ }
+
+ function persistWirelessAdbSettings() {
+ if (!pluginApi)
+ return;
+
+ pluginApi.pluginSettings.wirelessAdbPairHost = (wirelessAdbPairHost || "").trim();
+ pluginApi.pluginSettings.wirelessAdbPairPort = (wirelessAdbPairPort || "").trim();
+ pluginApi.pluginSettings.wirelessAdbConnectHost = (wirelessAdbConnectHost || "").trim();
+ pluginApi.pluginSettings.wirelessAdbConnectPort = (wirelessAdbConnectPort || "").trim();
+ pluginApi.saveSettings();
+ }
+
+ function openWirelessAdbDialog() {
+ if ((wirelessAdbConnectHost || "").trim() === "" && (wirelessAdbPairHost || "").trim() !== "")
+ wirelessAdbConnectHost = (wirelessAdbPairHost || "").trim();
+ if ((wirelessAdbPairHost || "").trim() === "" && (wirelessAdbConnectHost || "").trim() !== "")
+ wirelessAdbPairHost = (wirelessAdbConnectHost || "").trim();
+
+ wirelessAdbPairingCode = "";
+ wirelessAdbStatusMessage = "";
+ wirelessAdbPopup.open();
+ }
+
+ function startWirelessAdbPairing() {
+ const host = (wirelessAdbPairHost || "").trim();
+ const port = (wirelessAdbPairPort || "").trim();
+ const pairingCode = (wirelessAdbPairingCode || "").trim();
+
+ if (host === "" || port === "" || pairingCode === "") {
+ const body = trSafe("panel.wireless-adb.missing-pair-parameters-description", "Enter the phone IP, pairing port, and pairing code");
+ wirelessAdbStatusMessage = body;
+ KDEConnect.showWarningWithHistory(trSafe("panel.wireless-adb.error-title", "Wireless ADB"), body, 5000);
+ return;
+ }
+
+ wirelessAdbConnectHost = host;
+
+ wirelessAdbStatusMessage = "";
+ persistWirelessAdbSettings();
+ KDEConnect.pairWirelessAdb(host, port, pairingCode);
+ }
+
+ function startWirelessAdbConnect() {
+ const host = (wirelessAdbConnectHost || "").trim() !== ""
+ ? (wirelessAdbConnectHost || "").trim()
+ : (wirelessAdbPairHost || "").trim();
+ const port = (wirelessAdbConnectPort || "").trim();
+
+ if (host === "" || port === "") {
+ const body = trSafe("panel.wireless-adb.missing-connect-parameters-description", "Enter the phone IP and connect port");
+ wirelessAdbStatusMessage = body;
+ KDEConnect.showWarningWithHistory(trSafe("panel.wireless-adb.error-title", "Wireless ADB"), body, 5000);
+ return;
+ }
+
+ wirelessAdbConnectHost = host;
+ wirelessAdbStatusMessage = "";
+ wirelessAdbSessionPreferred = true;
+ persistWirelessAdbSettings();
+ KDEConnect.connectWirelessAdb(host, port);
+ }
+
+ function beginWirelessAdbQrPairing() {
+ if (KDEConnect.wirelessAdbBusy || wirelessAdbQrEncodeProc.running)
+ return;
+
+ wirelessAdbQrInstanceName = "noctalia-" + randomTokenFromAlphabet(10, "abcdefghijklmnopqrstuvwxyz0123456789");
+ wirelessAdbQrSecret = randomTokenFromAlphabet(10, "0123456789");
+ wirelessAdbStatusMessage = trSafe(
+ "panel.wireless-adb.qr-waiting-description",
+ "Waiting for the phone to scan the QR code and publish its pairing service."
+ );
+ wirelessAdbQrPendingLaunch = true;
+ wirelessAdbQrEncodeProc.running = true;
+ }
+
+ function applyWirelessAdbQrSuccess(message) {
+ const match = String(message || "").match(/^QR_OK\s+host=(\S+)\s+pair_port=(\d+)\s+connect_port=(\d+)/);
+ if (!match)
+ return false;
+
+ wirelessAdbPairHost = match[1];
+ wirelessAdbConnectHost = match[1];
+ wirelessAdbPairPort = match[2];
+ wirelessAdbConnectPort = match[3];
+ persistWirelessAdbSettings();
+ return true;
+ }
+
+ function configuredWirelessAdbSerial() {
+ const host = (wirelessAdbConnectHost || "").trim();
+ const port = (wirelessAdbConnectPort || "").trim();
+ if (host === "" || port === "")
+ return "";
+
+ return host + ":" + port;
+ }
+
+ function currentMirrorAdbSerial() {
+ const activeSerial = String(KDEConnect.scrcpyActiveSerial || "").trim();
+ if (KDEConnect.scrcpyRunning && activeSerial !== "")
+ return activeSerial;
+
+ return resolvedAdbSerial();
+ }
+
+ function resolvedAdbSerial() {
+ const usbTransportAvailable = KDEConnect.adbHasUsbTransport;
+ if (usbTransportAvailable && !wirelessAdbSessionPreferred)
+ return KDEConnect.usbSelectionSentinel;
+
+ const activeWirelessSerial = KDEConnect.adbConnectedSerialForHost((wirelessAdbConnectHost || "").trim());
+ if (activeWirelessSerial !== "")
+ return activeWirelessSerial;
+
+ const wirelessSerial = configuredWirelessAdbSerial();
+ if (wirelessSerial !== "") {
+ if (wirelessAdbSessionPreferred)
+ return wirelessSerial;
+
+ if (!usbTransportAvailable && KDEConnect.adbDeviceSerialConnected(wirelessSerial))
+ return wirelessSerial;
+ }
+
+ return KDEConnect.usbSelectionSentinel;
+ }
+
+ function syncBackgroundRefreshPolicy() {
+ KDEConnect.reduceBackgroundRefresh = root.visible
+ && root.reduceBackgroundRefreshWhileMirroring
+ && KDEConnect.scrcpyRunning;
+ }
+
+ function embeddedMirrorModeEnabled() {
+ return true;
+ }
+
+ function embeddedMirrorFeedConfigured() {
+ return (embeddedVideoDevice || "").trim() !== "";
+ }
+
+ function scheduleTouchMappingRefresh() {
+ Qt.callLater(function() {
+ root.refreshEmbeddedMirrorTouchMapping();
+ });
+ }
+
+ function clearPanelOpenUnlockState() {
+ root.panelOpenUnlockPending = false;
+ root.panelOpenUnlockRetriesRemaining = 0;
+ }
+
+ function retryPanelOpenUnlock() {
+ if (root.panelOpenUnlockRetriesRemaining > 0) {
+ root.panelOpenUnlockRetriesRemaining -= 1;
+ panelOpenUnlockTimer.restart();
+ return;
+ }
+
+ root.clearPanelOpenUnlockState();
+ }
+
+ function resetEmbeddedVideoDeviceAccess(checkKnown) {
+ embeddedVideoDeviceAccessible = false;
+ embeddedVideoDeviceCheckKnown = Boolean(checkKnown);
+ }
+
+ function refreshEmbeddedVideoDeviceAccess() {
+ if (!embeddedMirrorFeedConfigured()) {
+ resetEmbeddedVideoDeviceAccess(true);
+ embeddedVideoDeviceLastCheckAtMs = Date.now();
+ return;
+ }
+
+ if (embeddedVideoDeviceCheckProc.running)
+ return;
+
+ if (!embeddedVideoDeviceCheckKnown)
+ resetEmbeddedVideoDeviceAccess(false);
+ embeddedVideoDeviceCheckProc.running = true;
+ }
+
+ function ensureEmbeddedVideoDeviceAccessFresh(maxAgeMs) {
+ if (!embeddedMirrorFeedConfigured() || embeddedVideoDeviceCheckProc.running)
+ return;
+
+ const maxAge = Math.max(0, Number(maxAgeMs || 0));
+ const lastCheckedAt = Number(embeddedVideoDeviceLastCheckAtMs || 0);
+ if (maxAge > 0 && lastCheckedAt > 0 && (Date.now() - lastCheckedAt) < maxAge)
+ return;
+
+ refreshEmbeddedVideoDeviceAccess();
+ }
+
+ function toggleEmbeddedMirrorAudioMode(preview) {
+ if (!embeddedMirrorModeEnabled())
+ return;
+
+ embeddedMirrorAudioEnabled = !embeddedMirrorAudioEnabled;
+
+ if (KDEConnect.scrcpyRunning && !KDEConnect.scrcpyLaunching)
+ KDEConnect.stopScrcpySession();
+ }
+
+ function ensureEmbeddedMirrorSession(preview) {
+ if (!embeddedMirrorModeEnabled() || KDEConnect.mainDevice === null)
+ return;
+
+ const serial = resolvedAdbSerial();
+
+ if (embeddedMirrorFeedConfigured() && !embeddedVideoDeviceCheckKnown && !embeddedVideoDeviceCheckProc.running) {
+ refreshEmbeddedVideoDeviceAccess();
+ }
+
+ if (!KDEConnect.scrcpyRunning && !KDEConnect.scrcpyLaunching) {
+ const tunedEmbeddedCommand = KDEConnect.applyConfiguredMirrorAudioMode(
+ embeddedMirrorCommand,
+ embeddedMirrorAudioEnabled
+ );
+ const launchCommand = KDEConnect.buildScrcpyFeedCommand(
+ tunedEmbeddedCommand,
+ embeddedVideoDevice,
+ serial
+ );
+ Logger.i("KDEConnect", "Launching embedded scrcpy in feed mode");
+ KDEConnect.launchScrcpySession(
+ KDEConnect.mainDevice.id,
+ launchCommand
+ );
+ return;
+ }
+
+ if (KDEConnect.scrcpyRunning) {
+ refreshEmbeddedMirrorTouchMapping();
+ }
+ }
+
+ function embeddedMirrorViewActive(preview) {
+ return KDEConnect.scrcpyRunning
+ && Boolean(preview?.mirrorDisplayVisible);
+ }
+
+ function embeddedMirrorFeedReattaching(preview) {
+ const previewItem = preview || root.activePhonePreview || null;
+ return Boolean(previewItem?.mirrorFeedAttachDelayActive);
+ }
+
+ function embeddedMirrorTouchActive() {
+ return embeddedMirrorModeEnabled()
+ && KDEConnect.scrcpyRunning
+ && KDEConnect.adbDisplayInfoSerial === ""
+ && KDEConnect.adbScreenError === ""
+ && KDEConnect.adbScreenWidth > 0
+ && KDEConnect.adbScreenHeight > 0;
+ }
+
+ function embeddedMirrorInputActive() {
+ return embeddedMirrorModeEnabled()
+ && KDEConnect.scrcpyRunning;
+ }
+
+ function embeddedMirrorNavRowVisible() {
+ return embeddedMirrorModeEnabled();
+ }
+
+ function refreshEmbeddedMirrorTouchMapping() {
+ if (!embeddedMirrorModeEnabled()
+ || !KDEConnect.scrcpyRunning)
+ return;
+
+ const serial = currentMirrorAdbSerial();
+ const hasValidMapping = KDEConnect.adbScreenWidth > 0
+ && KDEConnect.adbScreenHeight > 0
+ && KDEConnect.adbScreenError === ""
+ && KDEConnect.adbDisplayInfoSerial === ""
+ && KDEConnect.adbScreenSerial === serial;
+
+ if (!hasValidMapping)
+ KDEConnect.queryAdbDisplayInfo(serial);
+ }
+
+ function refreshPanelOpenUnlockState() {
+ if (!root.panelOpenUnlockPending
+ || !root.embeddedMirrorModeEnabled()
+ || !KDEConnect.scrcpyRunning)
+ return;
+
+ const serial = currentMirrorAdbSerial();
+ if (serial === "")
+ return;
+
+ KDEConnect.queryAdbScreenState(serial);
+ }
+
+ function embeddedMirrorDrawerStatusVisible(preview) {
+ if (!embeddedMirrorModeEnabled())
+ return false;
+
+ if (!panelStatusGraceElapsed)
+ return false;
+
+ return String(embeddedMirrorDrawerStatusTitle(preview) || "").trim() !== ""
+ || String(embeddedMirrorDrawerStatusSubtitle(preview) || "").trim() !== "";
+ }
+
+ function embeddedMirrorPhoneOverlayVisible() {
+ if (!embeddedMirrorModeEnabled())
+ return true;
+
+ if (!panelStatusGraceElapsed)
+ return false;
+
+ return KDEConnect.scrcpyLaunching;
+ }
+
+ function embeddedMirrorPhoneStatusTitle(preview) {
+ return embeddedMirrorPhoneOverlayVisible()
+ ? embeddedMirrorStatusTitle(preview)
+ : "";
+ }
+
+ function embeddedMirrorPhoneStatusSubtitle(preview) {
+ return embeddedMirrorPhoneOverlayVisible()
+ ? embeddedMirrorStatusSubtitle(preview)
+ : "";
+ }
+
+ function embeddedMirrorDrawerStatusTitle(preview) {
+ return embeddedMirrorStatusTitle(preview);
+ }
+
+ function embeddedMirrorDrawerStatusSubtitle(preview) {
+ return embeddedMirrorStatusSubtitle(preview);
+ }
+
+ function embeddedMirrorStatusTitle(preview) {
+ const adbIssueTitle = adbSetupIssueTitle();
+ if (adbIssueTitle !== "")
+ return adbIssueTitle;
+
+ if (embeddedMirrorFeedConfigured() && embeddedVideoDeviceCheckKnown && !embeddedVideoDeviceAccessible)
+ return trSafe("panel.embedded-mirror.feed-unavailable-title", "Video Feed Unavailable");
+
+ if (KDEConnect.scrcpyLaunching)
+ return trSafe("panel.embedded-mirror.starting-title", "Starting Embedded Mirror");
+
+ if (KDEConnect.scrcpyLaunchError !== "")
+ return trSafe("panel.embedded-mirror.error-title", "Mirror Error");
+
+ if (embeddedMirrorFeedConfigured()
+ && KDEConnect.scrcpyRunning
+ && preview
+ && !embeddedMirrorFeedReattaching(preview)
+ && !preview.mirrorFeedAvailable
+ && Number(KDEConnect.scrcpyLaunchStartedAtMs || 0) > 0
+ && (Date.now() - Number(KDEConnect.scrcpyLaunchStartedAtMs || 0)) >= 5000)
+ return trSafe("panel.embedded-mirror.feed-starting-title", "Waiting for Video Feed");
+
+ if (embeddedMirrorFeedConfigured()
+ && KDEConnect.scrcpyRunning
+ && !embeddedMirrorFeedReattaching(preview)
+ && !embeddedMirrorViewActive(preview)
+ && Number(KDEConnect.scrcpyLaunchStartedAtMs || 0) > 0
+ && (Date.now() - Number(KDEConnect.scrcpyLaunchStartedAtMs || 0)) >= 5000)
+ return trSafe("panel.embedded-mirror.feed-starting-title", "Waiting for Video Feed");
+
+ if (KDEConnect.scrcpyRunning && KDEConnect.adbScreenError !== "")
+ return trSafe("panel.embedded-mirror.touch-error-title", "Touch Input Unavailable");
+
+ if (KDEConnect.scrcpyRunning && !embeddedMirrorTouchActive())
+ return trSafe("panel.embedded-mirror.touch-starting-title", "Preparing Touch Input");
+
+ return "";
+ }
+
+ function embeddedMirrorStatusSubtitle(preview) {
+ const adbIssueSubtitle = adbSetupIssueSubtitle();
+ if (adbIssueSubtitle !== "")
+ return adbIssueSubtitle;
+
+ if (embeddedMirrorFeedConfigured() && embeddedVideoDeviceCheckKnown && !embeddedVideoDeviceAccessible)
+ return trSafe("panel.embedded-mirror.feed-unavailable-description",
+ "The V4L2 device cannot be opened. Make sure "
+ + embeddedVideoDevice + " exists, is writable, and is backed by the scrcpy loopback device.");
+
+ if (KDEConnect.scrcpyLaunching)
+ return trSafe("panel.embedded-mirror.starting-description", "Launching scrcpy and preparing the live feed.");
+
+ if (KDEConnect.scrcpyLaunchError !== "")
+ return KDEConnect.scrcpyLaunchError;
+
+ if (embeddedMirrorFeedConfigured()
+ && KDEConnect.scrcpyRunning
+ && preview
+ && !embeddedMirrorFeedReattaching(preview)
+ && !preview.mirrorFeedAvailable
+ && Number(KDEConnect.scrcpyLaunchStartedAtMs || 0) > 0
+ && (Date.now() - Number(KDEConnect.scrcpyLaunchStartedAtMs || 0)) >= 5000) {
+ return trSafe("panel.embedded-mirror.feed-starting-description", "Waiting for the scrcpy video feed to appear in the embedded preview.");
+ }
+
+ if (embeddedMirrorFeedConfigured()
+ && KDEConnect.scrcpyRunning
+ && !embeddedMirrorFeedReattaching(preview)
+ && !embeddedMirrorViewActive(preview)
+ && Number(KDEConnect.scrcpyLaunchStartedAtMs || 0) > 0
+ && (Date.now() - Number(KDEConnect.scrcpyLaunchStartedAtMs || 0)) >= 5000) {
+ const feedError = preview && preview.mirrorFeedError !== ""
+ ? (" Preview failed: " + preview.mirrorFeedError)
+ : "";
+ return trSafe("panel.embedded-mirror.feed-starting-description", "Waiting for the scrcpy video feed to appear in the embedded preview.")
+ + feedError;
+ }
+
+ if (KDEConnect.scrcpyRunning && KDEConnect.adbScreenError !== "")
+ return KDEConnect.adbScreenError;
+
+ if (KDEConnect.scrcpyRunning && !embeddedMirrorTouchActive())
+ return trSafe("panel.embedded-mirror.touch-starting-description", "Querying the Android display size so taps and swipes line up with the mirror.");
+
+ return "";
+ }
+ Process {
+ id: embeddedVideoDeviceCheckProc
+ running: false
+ command: ["sh", "-lc",
+ "device=" + KDEConnect.shellQuote(root.embeddedVideoDevice)
+ + "; [ -c \"$device\" ] || exit 1"
+ + "; [ -w \"$device\" ] || exit 1"
+ + "; if command -v udevadm >/dev/null 2>&1; then"
+ + " props=$(udevadm info -q property -n \"$device\" 2>/dev/null || true)"
+ + "; if printf '%s\\n' \"$props\" | grep -Eq 'ID_V4L_CAPABILITIES=.*:video_(capture|output):'; then"
+ + " exit 0"
+ + "; fi"
+ + "; fi"
+ + "; if command -v v4l2-ctl >/dev/null 2>&1; then"
+ + " v4l2-ctl -D -d \"$device\" 2>/dev/null | grep -Eq 'Video (Capture|Output)'"
+ + "; else"
+ + " [ -r \"$device\" ]"
+ + "; fi"
+ ]
+
+ onExited: (exitCode, exitStatus) => {
+ root.embeddedVideoDeviceAccessible = exitCode === 0;
+ root.embeddedVideoDeviceCheckKnown = true;
+ root.embeddedVideoDeviceLastCheckAtMs = Date.now();
+
+ if (exitCode !== 0) {
+ Logger.w("KDEConnect", "Embedded V4L2 device check failed:", root.embeddedVideoDevice);
+ }
+ if (!KDEConnect.scrcpyRunning && !KDEConnect.scrcpyLaunching)
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+ }
+
+ Process {
+ id: embeddedMirrorFormatLockProc
+ running: false
+ command: ["sh", "-lc",
+ "device=" + KDEConnect.shellQuote(root.embeddedVideoDevice)
+ + "; [ -c \"$device\" ] || exit 2"
+ + "; base=/sys/devices/virtual/video4linux/$(basename \"$device\")"
+ + "; i=0; fmt=''; prev_fmt=''; stable_fmt=''"
+ + "; while [ $i -lt 40 ]; do"
+ + " fmt=$(cat \"$base/format\" 2>/dev/null || true)"
+ + "; if [ -n \"$fmt\" ] && [ \"$fmt\" = \"$prev_fmt\" ]; then stable_fmt=\"$fmt\"; break; fi"
+ + "; [ -n \"$fmt\" ] && prev_fmt=\"$fmt\""
+ + "; i=$((i+1))"
+ + "; sleep 0.05"
+ + "; done"
+ + "; [ -n \"$stable_fmt\" ] && fmt=\"$stable_fmt\" || fmt=\"$prev_fmt\""
+ + "; [ -n \"$fmt\" ] || exit 3"
+ + "; v4l2-ctl -d \"$device\" -c keep_format=1 >/dev/null 2>&1 || exit 4"
+ + "; i=0; ready=0"
+ + "; while [ $i -lt 60 ]; do"
+ + " if v4l2-ctl -D -d \"$device\" >/dev/null 2>&1"
+ + " && v4l2-ctl --list-formats-ext -d \"$device\" >/dev/null 2>&1; then ready=1; break; fi"
+ + "; i=$((i+1))"
+ + "; sleep 0.05"
+ + "; done"
+ + "; [ \"$ready\" = 1 ] || exit 5"
+ + "; printf 'locked_format=%s\\n' \"$fmt\""
+ + "; printf 'consumer_open_ready=1\\n'"
+ + "; v4l2-ctl -d \"$device\" -C keep_format 2>/dev/null | sed 's/^/keep_format=/'"
+ ]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const output = String(text || "").trim();
+ if (output !== "") {
+ Logger.i("KDEConnect", "Embedded format lock output:\n" + output);
+ if (root.activePhonePreview) {
+ const lines = output.split("\n");
+ for (let i = 0; i < lines.length; ++i) {
+ const line = String(lines[i] || "").trim();
+ if (line !== "")
+ root.activePhonePreview.debugLog("formatLock " + line);
+ }
+ }
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ const output = String(text || "").trim();
+ if (output !== "") {
+ Logger.w("KDEConnect", "Embedded format lock stderr:", output);
+ if (root.activePhonePreview)
+ root.activePhonePreview.debugLog("formatLock stderr=" + output);
+ }
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ Logger.i("KDEConnect", "Embedded format lock exited:", exitCode);
+ if (root.activePhonePreview)
+ root.activePhonePreview.debugLog("formatLock exitCode=" + exitCode);
+ if (exitCode === 0 && root.activePhonePreview) {
+ root.embeddedMirrorFormatLockRetryCount = 0;
+ Qt.callLater(function() {
+ if (root.activePhonePreview)
+ root.activePhonePreview.probeNativeLoopback();
+ });
+ } else if (exitCode === 5
+ && root.activePhonePreview
+ && root.activePhonePreview.mirrorFeedEnabled
+ && root.embeddedMirrorFormatLockRetryCount < 3) {
+ root.embeddedMirrorFormatLockRetryCount += 1;
+ root.activePhonePreview.debugLog("formatLock retry attempt=" + root.embeddedMirrorFormatLockRetryCount);
+ embeddedMirrorFormatLockRetryTimer.restart();
+ } else {
+ root.embeddedMirrorFormatLockRetryCount = 0;
+ }
+ }
+ }
+
+ Process {
+ id: wirelessAdbQrEncodeProc
+ running: false
+ command: [
+ "qrencode",
+ "-o", root.wirelessAdbQrImagePath,
+ "-s", "10",
+ "-m", "1",
+ root.wirelessAdbQrPayload()
+ ]
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode === 0) {
+ root.wirelessAdbQrImageVersion += 1;
+ if (root.wirelessAdbQrPendingLaunch) {
+ root.wirelessAdbQrPendingLaunch = false;
+ KDEConnect.pairWirelessAdbByQr(
+ root.wirelessAdbQrInstanceName,
+ root.wirelessAdbQrSecret,
+ 90
+ );
+ }
+ return;
+ }
+
+ root.wirelessAdbQrPendingLaunch = false;
+ const body = root.trSafe("panel.wireless-adb.qr-generate-error-description", "Failed to generate the Wireless ADB QR code.");
+ root.wirelessAdbStatusMessage = body;
+ KDEConnect.showWarningWithHistory(root.trSafe("panel.wireless-adb.error-title", "Wireless ADB"), body, 5000);
+ }
+ }
+
+ function normalizedToDeviceCoordinate(value, maxValue) {
+ if (maxValue <= 0)
+ return 0;
+
+ return Math.max(0, Math.min(maxValue - 1, Math.round(value * maxValue)));
+ }
+
+ function handleMirrorTap(xNorm, yNorm) {
+ if (KDEConnect.adbScreenWidth <= 0 || KDEConnect.adbScreenHeight <= 0)
+ return;
+
+ KDEConnect.runAdbTap(
+ currentMirrorAdbSerial(),
+ normalizedToDeviceCoordinate(xNorm, KDEConnect.adbScreenWidth),
+ normalizedToDeviceCoordinate(yNorm, KDEConnect.adbScreenHeight)
+ );
+ }
+
+ function handleMirrorSwipe(x1Norm, y1Norm, x2Norm, y2Norm, durationMs) {
+ if (KDEConnect.adbScreenWidth <= 0 || KDEConnect.adbScreenHeight <= 0)
+ return;
+
+ KDEConnect.runAdbSwipe(
+ currentMirrorAdbSerial(),
+ normalizedToDeviceCoordinate(x1Norm, KDEConnect.adbScreenWidth),
+ normalizedToDeviceCoordinate(y1Norm, KDEConnect.adbScreenHeight),
+ normalizedToDeviceCoordinate(x2Norm, KDEConnect.adbScreenWidth),
+ normalizedToDeviceCoordinate(y2Norm, KDEConnect.adbScreenHeight),
+ durationMs
+ );
+ }
+
+ function handleMirrorScroll(xNorm, yNorm, deltaX, deltaY) {
+ if (KDEConnect.adbScreenWidth <= 0 || KDEConnect.adbScreenHeight <= 0)
+ return;
+
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+ if (absDeltaX === 0 && absDeltaY === 0)
+ return;
+
+ const startX = normalizedToDeviceCoordinate(xNorm, KDEConnect.adbScreenWidth);
+ const startY = normalizedToDeviceCoordinate(yNorm, KDEConnect.adbScreenHeight);
+ const horizontalScroll = absDeltaX > absDeltaY;
+ const magnitude = Math.max(0.65, Math.min(2.4, horizontalScroll ? absDeltaX : absDeltaY));
+
+ if (horizontalScroll) {
+ const travelX = Math.max(72, Math.round(KDEConnect.adbScreenWidth * 0.075 * magnitude));
+ const halfTravelX = Math.max(24, Math.round(travelX / 2));
+ const swipeStartX = Math.max(0, Math.min(KDEConnect.adbScreenWidth - 1, startX + (deltaX > 0 ? halfTravelX : -halfTravelX)));
+ const swipeEndX = Math.max(0, Math.min(KDEConnect.adbScreenWidth - 1, startX + (deltaX > 0 ? -halfTravelX : halfTravelX)));
+ KDEConnect.runAdbSwipe(
+ currentMirrorAdbSerial(),
+ swipeStartX,
+ startY,
+ swipeEndX,
+ startY,
+ 115
+ );
+ return;
+ }
+
+ const travelY = Math.max(110, Math.round(KDEConnect.adbScreenHeight * 0.11 * magnitude));
+ const halfTravelY = Math.max(32, Math.round(travelY / 2));
+ const swipeStartY = Math.max(0, Math.min(KDEConnect.adbScreenHeight - 1, startY + (deltaY < 0 ? halfTravelY : -halfTravelY)));
+ const swipeEndY = Math.max(0, Math.min(KDEConnect.adbScreenHeight - 1, startY + (deltaY < 0 ? -halfTravelY : halfTravelY)));
+ KDEConnect.runAdbSwipe(
+ currentMirrorAdbSerial(),
+ startX,
+ swipeStartY,
+ startX,
+ swipeEndY,
+ 125
+ );
+ }
+
+ function sendAndroidNavKey(keyCode) {
+ KDEConnect.runAdbKeyevent(currentMirrorAdbSerial(), keyCode);
+ }
+
+ function sendKeyboardText(text) {
+ if (!embeddedMirrorInputActive())
+ return;
+
+ KDEConnect.runAdbText(currentMirrorAdbSerial(), text);
+ }
+
+ function sendKeyboardKey(keyCode) {
+ if (!embeddedMirrorInputActive())
+ return;
+
+ KDEConnect.runAdbKeyevent(currentMirrorAdbSerial(), keyCode);
+ }
+
+ function sendAndroidHomeOrUnlock() {
+ if (!embeddedMirrorInputActive())
+ return;
+
+ const serial = currentMirrorAdbSerial();
+ KDEConnect.runAdbKeyevent(serial, 224); // WAKEUP
+ KDEConnect.runAdbKeyevent(serial, 3); // HOME
+ }
+
+ function sendAndroidUnlockOnly() {
+ if (!embeddedMirrorInputActive())
+ return;
+
+ const serial = currentMirrorAdbSerial();
+ if (serial === "")
+ return;
+
+ const hasFreshState = KDEConnect.hasFreshAdbScreenState(serial);
+ const shouldWake = !hasFreshState || !KDEConnect.adbScreenInteractive;
+ const shouldUnlock = hasFreshState && KDEConnect.adbScreenLockState === "true";
+
+ if (shouldWake)
+ KDEConnect.runAdbKeyevent(serial, 224); // WAKEUP
+ if (shouldUnlock)
+ KDEConnect.runAdbKeyevent(serial, 82); // MENU / dismiss keyguard
+ }
+
+ function takeMirrorScreenshot() {
+ if (!embeddedMirrorInputActive())
+ return;
+
+ KDEConnect.takeAdbScreenshot(currentMirrorAdbSerial());
+ }
+
+ function toggleMirrorScreenRecording() {
+ if (KDEConnect.adbScreenRecordingActive) {
+ KDEConnect.stopAdbScreenRecording();
+ return;
+ }
+
+ if (!embeddedMirrorInputActive())
+ return;
+
+ KDEConnect.startAdbScreenRecording(currentMirrorAdbSerial());
+ }
+
+ function toggleKeepScreenOnWhilePanelOpen() {
+ const serial = String(keepScreenOnSerial || currentMirrorAdbSerial() || "").trim();
+ if (serial === "")
+ return;
+
+ if (keepScreenOnEnabled) {
+ restoreKeepScreenOnState();
+ return;
+ }
+
+ keepScreenOnSerial = serial;
+ if (KDEConnect.hasFreshAdbScreenTimeout(serial)) {
+ keepScreenOnPending = false;
+ keepScreenOnEnabled = true;
+ keepScreenOnOriginalTimeout = String(KDEConnect.adbScreenTimeoutValue || "").trim();
+ KDEConnect.setAdbScreenTimeout(serial, String(keepScreenOnTimeoutMs));
+ return;
+ }
+
+ keepScreenOnPending = true;
+ KDEConnect.queryAdbScreenTimeout(serial);
+ }
+
+ function toggleMirrorScreenDim() {
+ const serial = String(dimScreenSerial || currentMirrorAdbSerial() || "").trim();
+ if (serial === "")
+ return;
+
+ if (dimScreenEnabled) {
+ restoreDimScreenState();
+ return;
+ }
+
+ dimScreenSerial = serial;
+ if (KDEConnect.hasFreshAdbScreenBrightness(serial)) {
+ dimScreenPending = false;
+ dimScreenEnabled = true;
+ dimScreenOriginalMode = String(KDEConnect.adbScreenBrightnessMode || "").trim();
+ dimScreenOriginalBrightness = String(KDEConnect.adbScreenBrightnessValue || "").trim();
+ KDEConnect.setAdbScreenBrightness(serial, String(dimScreenBrightnessValue));
+ return;
+ }
+
+ dimScreenPending = true;
+ KDEConnect.queryAdbScreenBrightness(serial);
+ }
+
+ function restoreKeepScreenOnState() {
+ const serial = String(keepScreenOnSerial || "").trim();
+ keepScreenOnPending = false;
+ if (serial !== "" && keepScreenOnEnabled)
+ KDEConnect.restoreAdbScreenTimeout(serial, keepScreenOnOriginalTimeout);
+
+ keepScreenOnEnabled = false;
+ keepScreenOnSerial = "";
+ keepScreenOnOriginalTimeout = "";
+ }
+
+ function restoreDimScreenState() {
+ const serial = String(dimScreenSerial || "").trim();
+ dimScreenPending = false;
+ if (serial !== "" && dimScreenEnabled)
+ KDEConnect.restoreAdbScreenBrightness(serial, dimScreenOriginalMode, dimScreenOriginalBrightness);
+
+ dimScreenEnabled = false;
+ dimScreenSerial = "";
+ dimScreenOriginalMode = "";
+ dimScreenOriginalBrightness = "";
+ }
+
+ component NavActionButton: Rectangle {
+ id: navButton
+
+ property string iconName: ""
+ property string label: ""
+ property string tooltipText: ""
+ property bool actionEnabled: true
+ property bool active: false
+ property bool circular: false
+ property real sizeScale: root.navButtonScaleFactor
+ property real circularSize: 46 * Style.uiScaleRatio * sizeScale
+ signal pressed
+
+ implicitWidth: circular
+ ? circularSize
+ : navButtonContent.implicitWidth + (16 * Style.uiScaleRatio * sizeScale)
+ implicitHeight: circular
+ ? circularSize
+ : 36 * Style.uiScaleRatio * sizeScale
+ radius: circular ? width / 2 : 13 * Style.uiScaleRatio * sizeScale
+ scale: navButton.circular
+ ? (navMouse.pressed
+ ? 0.94
+ : (navMouse.containsMouse ? 1.08 : 1.0))
+ : 1.0
+ color: circular
+ ? (navButton.active
+ ? Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.18)
+ : (navMouse.containsMouse
+ ? Color.mHover
+ : Color.mSurfaceVariant))
+ : (navButton.active
+ ? Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.16)
+ : (navMouse.containsMouse
+ ? Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, 0.96)
+ : Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, 0.82)))
+ border.width: Style.borderS
+ border.color: circular
+ ? (navButton.active
+ ? Color.mPrimary
+ : (navMouse.containsMouse
+ ? Color.mOutline
+ : Color.mOutline))
+ : (navButton.active
+ ? Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.52)
+ : (navMouse.containsMouse
+ ? Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.32)
+ : Qt.rgba(Color.mOutline.r, Color.mOutline.g, Color.mOutline.b, 0.22)))
+ opacity: actionEnabled ? 1.0 : 0.55
+
+ Behavior on color {
+ ColorAnimation { duration: 120 }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: 130
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ MouseArea {
+ id: navMouse
+ anchors.fill: parent
+ enabled: navButton.actionEnabled
+ hoverEnabled: navButton.actionEnabled
+ cursorShape: navButton.actionEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onEntered: {
+ if (navButton.tooltipText !== "")
+ TooltipService.show(navButton, navButton.tooltipText, "top");
+ }
+ onExited: {
+ if (navButton.tooltipText !== "")
+ TooltipService.hide(navButton);
+ }
+ onClicked: navButton.pressed()
+ }
+
+ RowLayout {
+ id: navButtonContent
+ anchors.centerIn: parent
+ spacing: Style.marginXS * navButton.sizeScale
+
+ NIcon {
+ icon: navButton.iconName
+ pointSize: (navButton.circular ? Style.fontSizeS : Style.fontSizeXS) * navButton.sizeScale
+ color: navButton.actionEnabled
+ ? (navButton.active
+ ? Color.mPrimary
+ : (navButton.circular
+ ? (navMouse.containsMouse ? Color.mOnHover : Color.mPrimary)
+ : Color.mOnSurface))
+ : Color.mOnSurfaceVariant
+ }
+
+ NText {
+ visible: !navButton.circular
+ text: navButton.label
+ pointSize: Style.fontSizeXXS * navButton.sizeScale
+ font.weight: Style.fontWeightMedium
+ color: navButton.actionEnabled ? Color.mOnSurface : Color.mOnSurfaceVariant
+ }
+ }
+ }
+
+ component PanelActionIconButton: NIconButton {
+ id: panelActionIconButton
+
+ property bool active: false
+
+ baseSize: Style.baseWidgetSize * 0.8
+ colorBg: active ? root.shellButtonActiveBgColor : root.shellButtonBgColor
+ colorFg: active ? root.shellButtonActiveFgColor : root.shellButtonFgColor
+ colorBgHover: active ? root.shellButtonActiveBgColor : root.shellButtonBgHoverColor
+ colorFgHover: active ? root.shellButtonActiveFgColor : root.shellButtonFgHoverColor
+ colorBorder: active ? root.shellButtonActiveBorderColor : root.shellButtonBorderColor
+ colorBorderHover: active ? root.shellButtonActiveBorderColor : root.shellButtonBorderHoverColor
+ }
+
+ component UtilityActionCard: NBox {
+ id: utilityCard
+
+ default property alias contentData: utilityCardContent.data
+
+ Layout.fillWidth: true
+ implicitHeight: utilityCardContent.implicitHeight + Style.margin2M
+
+ GridLayout {
+ id: utilityCardContent
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ rows: 1
+ flow: GridLayout.LeftToRight
+ columnSpacing: Style.marginM
+ rowSpacing: 0
+ }
+ }
+
+ Rectangle {
+ id: panelContainer
+ anchors.fill: parent
+ color: "transparent"
+
+ ColumnLayout {
+ id: deviceData
+
+ function getBatteryIcon(percentage, isCharging) {
+ if (percentage < 0) return "battery-exclamation"
+ if (isCharging) return "battery-charging-2"
+ if (percentage < 5) return "battery"
+ if (percentage < 25) return "battery-1"
+ if (percentage < 50) return "battery-2"
+ if (percentage < 75) return "battery-3"
+ return "battery-4"
+ }
+
+ function getCellularTypeIcon(type) {
+ const normalizedType = String(type || "").trim().toUpperCase();
+ if (normalizedType === "")
+ return "wave-square";
+
+ if (normalizedType.indexOf("5G") !== -1 || normalizedType.indexOf("NR") !== -1)
+ return "signal-5g";
+
+ if (normalizedType.indexOf("LTE") !== -1)
+ return "signal-lte";
+
+ if (normalizedType.indexOf("4G") !== -1)
+ return "signal-4g";
+
+ if (normalizedType.indexOf("HSPA") !== -1 || normalizedType.indexOf("H+") !== -1 || normalizedType === "H")
+ return "signal-h";
+
+ if (normalizedType.indexOf("UMTS") !== -1
+ || normalizedType.indexOf("WCDMA") !== -1
+ || normalizedType.indexOf("EVDO") !== -1
+ || normalizedType.indexOf("CDMA2000") !== -1
+ || normalizedType === "CDMA"
+ || normalizedType.indexOf("3G") !== -1) {
+ return "signal-3g";
+ }
+
+ if (normalizedType.indexOf("EDGE") !== -1 || normalizedType === "E")
+ return "signal-e";
+
+ if (normalizedType.indexOf("GPRS") !== -1 || normalizedType === "G")
+ return "signal-g";
+
+ if (normalizedType.indexOf("GSM") !== -1
+ || normalizedType.indexOf("IDEN") !== -1
+ || normalizedType.indexOf("2G") !== -1) {
+ return "signal-2g";
+ }
+
+ return "wave-square";
+ }
+
+ function getCellularStrengthIcon(strength) {
+ switch (strength) {
+ case 0:
+ return "antenna-bars-1"
+ case 1:
+ return "antenna-bars-2"
+ case 2:
+ return "antenna-bars-3"
+ case 3:
+ return "antenna-bars-4"
+ case 4:
+ return "antenna-bars-5"
+ default:
+ return "antenna-bars-off"
+ }
+ }
+
+ function getSignalStrengthText(strength) {
+ switch (strength) {
+ case 0:
+ return pluginApi?.tr("panel.signal.very-weak")
+ case 1:
+ return pluginApi?.tr("panel.signal.weak")
+ case 2:
+ return pluginApi?.tr("panel.signal.fair")
+ case 3:
+ return pluginApi?.tr("panel.signal.good")
+ case 4:
+ return pluginApi?.tr("panel.signal.excellent")
+ default:
+ return pluginApi?.tr("panel.unknown")
+ }
+ }
+
+ anchors {
+ fill: parent
+ margins: Style.marginM
+ }
+ spacing: Style.marginM
+
+ Loader {
+ Layout.fillWidth: true
+ Layout.fillHeight: !root.mainDeviceSetupComplete()
+ Layout.alignment: Qt.AlignTop
+ active: true
+ sourceComponent: (KDEConnect.busctlCmd === null || KDEConnect.busctlCmd === "") ? busctlNotFoundCard :
+ (!KDEConnect.daemonAvailable) ? kdeConnectDaemonNotRunningCard :
+ (deviceSwitcherOpen) ? deviceSwitcherCard :
+ (root.mainDeviceSetupComplete()) ? deviceConnectedCard :
+ (root.mainDevicePairingInProgress()) ? noDevicePairedCard :
+ (KDEConnect.mainDevice !== null || KDEConnect.devices.length > 0) ? setupRequiredCard :
+ (KDEConnect.devices.length === 0) ? noDevicesAvailableCard :
+ null
+ }
+
+ Component {
+ id: deviceConnectedCard
+
+ Rectangle {
+ Layout.fillWidth: true
+ color: "transparent"
+ radius: Style.radiusL
+ implicitHeight: contentLayout.implicitHeight + (Style.marginS * 2)
+
+ ColumnLayout {
+ id: contentLayout
+ anchors {
+ fill: parent
+ margins: Style.marginS
+ }
+ spacing: Style.marginM
+
+ NFilePicker {
+ id: shareFilePicker
+ title: pluginApi?.tr("panel.send-file-picker")
+ selectionMode: "files"
+ initialPath: Quickshell.env("HOME")
+ nameFilters: ["*"]
+ onAccepted: paths => {
+ if (paths.length > 0) {
+ for (const path of paths) {
+ KDEConnect.shareFile(KDEConnect.mainDevice.id, path)
+ }
+ }
+ }
+ }
+
+ Loader {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ active: KDEConnect.mainDevice !== null
+ sourceComponent: deviceStatsWithPhone
+ }
+
+ }
+
+ Component {
+ id: deviceStatsWithPhone
+
+ ColumnLayout {
+ spacing: Style.marginXS
+ Layout.fillWidth: true
+
+ Component.onCompleted: {
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+
+ Rectangle {
+ id: remoteStageCard
+ Layout.fillWidth: true
+ implicitHeight: remoteStageContent.implicitHeight + (Style.marginS * 2)
+ radius: Style.radiusL
+ color: "transparent"
+ border.width: 0
+ border.color: "transparent"
+
+ ColumnLayout {
+ id: remoteStageContent
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ spacing: Style.marginXS
+
+ NBox {
+ id: headerBox
+ Layout.fillWidth: true
+ implicitHeight: headerContent.implicitHeight + Style.margin2M
+
+ ColumnLayout {
+ id: headerContent
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Rectangle {
+ readonly property var brandBadge: root.deviceBrandBadge(KDEConnect.mainDevice?.name || "")
+ readonly property bool brandBadgeFrameless: brandBadge.source !== ""
+ Layout.alignment: Qt.AlignVCenter
+ Layout.preferredWidth: 34 * Style.uiScaleRatio
+ Layout.preferredHeight: 34 * Style.uiScaleRatio
+ radius: width / 2
+ color: brandBadgeFrameless ? "transparent" : root.shellIconChipColor
+ border.width: brandBadgeFrameless ? 0 : Style.borderS
+ border.color: brandBadgeFrameless ? "transparent" : root.shellIconChipBorderColor
+
+ Image {
+ anchors.centerIn: parent
+ visible: parent.brandBadge.source !== ""
+ source: parent.brandBadge.source
+ width: parent.brandBadgeFrameless ? parent.width : parent.width * 0.72
+ height: parent.brandBadgeFrameless ? parent.height : parent.height * 0.72
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ mipmap: true
+ }
+
+ NIcon {
+ anchors.centerIn: parent
+ visible: parent.brandBadge.source === ""
+ icon: parent.brandBadge.fallbackIcon
+ pointSize: Style.fontSizeS
+ color: root.shellPrimaryTextColor
+ }
+ }
+
+ NText {
+ text: KDEConnect.mainDevice.name
+ pointSize: Style.fontSizeL * 1.55
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ }
+
+ RowLayout {
+ Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+ spacing: Style.marginXS
+
+ PanelActionIconButton {
+ readonly property bool multipleDevices: KDEConnect.devices.length > 1
+ icon: "swipe"
+ tooltipText: multipleDevices ? pluginApi?.tr("panel.other-devices") : ""
+ onClicked: {
+ deviceSwitcherOpen = !deviceSwitcherOpen
+ }
+ enabled: KDEConnect.daemonAvailable && multipleDevices
+ opacity: multipleDevices ? 1.0 : 0.0
+ }
+
+ PanelActionIconButton {
+ icon: "zoom-in"
+ tooltipText: root.trSafe("panel.phone-size.tooltip", "Phone size: ")
+ + root.phoneSizeLabel + " (" + root.phoneSizePercent + "%)"
+ onClicked: root.cyclePhoneSizePreset()
+ }
+
+ PanelActionIconButton {
+ visible: root.embeddedMirrorModeEnabled()
+ icon: root.embeddedMirrorAudioEnabled ? "volume" : "volume-off"
+ tooltipText: root.embeddedMirrorAudioEnabled
+ ? root.trSafe("panel.embedded-mirror.audio-disable", "Disable embedded audio")
+ : root.trSafe("panel.embedded-mirror.audio-enable", "Enable embedded audio")
+ enabled: !KDEConnect.scrcpyLaunching
+ onClicked: root.toggleEmbeddedMirrorAudioMode()
+ }
+
+ PanelActionIconButton {
+ icon: "wifi"
+ tooltipText: KDEConnect.wirelessAdbBusy
+ ? root.trSafe("panel.wireless-adb.busy-tooltip", "Wireless ADB command is running")
+ : root.trSafe("panel.wireless-adb.tooltip", "Open Wireless ADB tools")
+ onClicked: root.openWirelessAdbDialog()
+ }
+
+ PanelActionIconButton {
+ icon: "device-mobile-search"
+ tooltipText: pluginApi?.tr("panel.browse-device")
+ onClicked: KDEConnect.browseFiles(KDEConnect.mainDevice.id)
+ }
+
+ PanelActionIconButton {
+ icon: "device-mobile-share"
+ tooltipText: pluginApi?.tr("panel.send-file")
+ onClicked: shareFilePicker.open()
+ }
+
+ PanelActionIconButton {
+ icon: "radar"
+ tooltipText: pluginApi?.tr("panel.find-device")
+ onClicked: KDEConnect.triggerFindMyPhone(KDEConnect.mainDevice.id)
+ }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Style.marginS
+
+ ColumnLayout {
+ id: phoneColumn
+ Layout.alignment: Qt.AlignTop
+ spacing: Style.marginXS
+
+ Item {
+ id: phonePreviewContainer
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: root.phoneBaseWidth * root.phoneSizeFactor
+ Layout.preferredHeight: root.phoneBaseHeight * root.phoneSizeFactor
+ implicitWidth: Layout.preferredWidth
+ implicitHeight: Layout.preferredHeight
+
+ Behavior on Layout.preferredWidth {
+ enabled: root.phoneSizeAnimationEnabled
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: root.panelResizeBezierCurve
+ }
+ }
+
+ Behavior on Layout.preferredHeight {
+ enabled: root.phoneSizeAnimationEnabled
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: root.panelResizeBezierCurve
+ }
+ }
+
+ PhoneDisplay {
+ id: phonePreview
+ anchors.fill: parent
+ mirrorFeedEnabled: KDEConnect.scrcpyRunning
+ scrcpyStartedAtMs: KDEConnect.scrcpyLaunchStartedAtMs
+ mirrorDeviceIdMatch: root.embeddedVideoDevice
+ mirrorDeviceDescriptionMatch: "scrcpy-panel"
+ mirrorContentWidth: KDEConnect.adbScreenWidth
+ mirrorContentHeight: KDEConnect.adbScreenHeight
+ interactiveScreen: root.embeddedMirrorTouchActive()
+ showStatusOverlay: root.embeddedMirrorPhoneOverlayVisible()
+ statusTitle: root.embeddedMirrorPhoneStatusTitle(phonePreview)
+ statusSubtitle: root.embeddedMirrorPhoneStatusSubtitle(phonePreview)
+ busy: KDEConnect.scrcpyLaunching
+ || (KDEConnect.scrcpyRunning
+ && (!phonePreview.mirrorFeedAvailable
+ || KDEConnect.adbDisplayInfoSerial !== ""))
+
+ Component.onCompleted: {
+ root.activePhonePreview = phonePreview;
+ root.scheduleEmbeddedMirrorAutoStart();
+ }
+
+ Component.onDestruction: {
+ if (root.activePhonePreview === phonePreview)
+ root.activePhonePreview = null;
+ }
+
+ onClicked: root.handlePhoneClick(phonePreview)
+ onTapRequested: (x, y) => root.handleMirrorTap(x, y)
+ onSwipeRequested: (x1, y1, x2, y2, durationMs) => root.handleMirrorSwipe(x1, y1, x2, y2, durationMs)
+ onScrollRequested: (x, y, deltaX, deltaY) => root.handleMirrorScroll(x, y, deltaX, deltaY)
+ onTextRequested: text => root.sendKeyboardText(text)
+ onKeyRequested: keyCode => root.sendKeyboardKey(keyCode)
+ onHomeRequested: root.sendAndroidHomeOrUnlock()
+ onRecentsRequested: root.sendAndroidNavKey(187)
+ }
+ }
+
+ RowLayout {
+ id: navRow
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: phonePreviewContainer.width
+ Layout.fillWidth: false
+ spacing: Style.marginS
+ visible: root.embeddedMirrorNavRowVisible()
+
+ Item { Layout.fillWidth: true }
+
+ NavActionButton {
+ circular: true
+ iconName: "arrow-back"
+ label: root.trSafe("panel.embedded-mirror.nav-back", "Back")
+ tooltipText: root.trSafe("panel.embedded-mirror.nav-back-tooltip", "Back, mouse right click")
+ actionEnabled: root.embeddedMirrorInputActive()
+ onPressed: root.sendAndroidNavKey(4)
+ }
+
+ Item { Layout.fillWidth: true }
+
+ NavActionButton {
+ circular: true
+ iconName: "home"
+ label: root.trSafe("panel.embedded-mirror.nav-home", "Home")
+ tooltipText: root.trSafe("panel.embedded-mirror.nav-home-tooltip", "Home, Home key")
+ actionEnabled: root.embeddedMirrorInputActive()
+ onPressed: root.sendAndroidNavKey(3)
+ }
+
+ Item { Layout.fillWidth: true }
+
+ NavActionButton {
+ circular: true
+ iconName: "layout-grid"
+ label: root.trSafe("panel.embedded-mirror.nav-recents", "Recents")
+ tooltipText: root.trSafe("panel.embedded-mirror.nav-recents-tooltip", "Task picker, mouse wheel press")
+ actionEnabled: root.embeddedMirrorInputActive()
+ onPressed: root.sendAndroidNavKey(187)
+ }
+
+ Item { Layout.fillWidth: true }
+ }
+
+ }
+
+ ColumnLayout {
+ id: rightInfoColumn
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+ Layout.topMargin: 12 * Style.uiScaleRatio
+ spacing: Style.marginL * 1.1
+
+ ColumnLayout {
+ id: deviceSummaryColumn
+ Layout.fillWidth: true
+ spacing: Style.marginL * 1.1
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NIcon {
+ icon: deviceData.getBatteryIcon(root.effectiveBatteryValue(KDEConnect.mainDevice), root.effectiveChargingValue(KDEConnect.mainDevice))
+ pointSize: Style.fontSizeXL * 1.2075
+ color: root.shellPrimaryIconColor
+ Layout.alignment: Qt.AlignTop
+ Layout.preferredWidth: 38 * Style.uiScaleRatio
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginXXS
+
+ NText {
+ text: pluginApi?.tr("panel.card.battery")
+ pointSize: Style.fontSizeS * 1.15
+ color: root.shellSecondaryTextColor
+ }
+
+ NText {
+ text: root.effectiveBatteryValue(KDEConnect.mainDevice) < 0
+ ? pluginApi?.tr("panel.unknown")
+ : (root.effectiveBatteryValue(KDEConnect.mainDevice) + "%")
+ pointSize: Style.fontSizeL * 1.288
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NIcon {
+ icon: deviceData.getCellularTypeIcon(root.effectiveNetworkType(KDEConnect.mainDevice))
+ pointSize: Style.fontSizeXL * 1.2075
+ color: root.shellPrimaryIconColor
+ Layout.alignment: Qt.AlignTop
+ Layout.preferredWidth: 38 * Style.uiScaleRatio
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginXXS
+
+ NText {
+ text: pluginApi?.tr("panel.card.network")
+ pointSize: Style.fontSizeS * 1.15
+ color: root.shellSecondaryTextColor
+ }
+
+ NText {
+ text: root.effectiveNetworkType(KDEConnect.mainDevice) || pluginApi?.tr("panel.unknown")
+ pointSize: Style.fontSizeL * 1.288
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NIcon {
+ icon: deviceData.getCellularStrengthIcon(root.effectiveSignalStrength(KDEConnect.mainDevice))
+ pointSize: Style.fontSizeXL * 1.2075
+ color: root.shellPrimaryIconColor
+ Layout.alignment: Qt.AlignTop
+ Layout.preferredWidth: 38 * Style.uiScaleRatio
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginXXS
+
+ NText {
+ text: root.trSafe("panel.card.signal", "Signal")
+ pointSize: Style.fontSizeS * 1.15
+ color: root.shellSecondaryTextColor
+ }
+
+ NText {
+ text: deviceData.getSignalStrengthText(root.effectiveSignalStrength(KDEConnect.mainDevice))
+ || pluginApi?.tr("panel.unknown")
+ pointSize: Style.fontSizeL * 1.288
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ }
+ }
+ }
+
+ UtilityActionCard {
+ id: mirrorUtilityCard
+ visible: root.embeddedMirrorModeEnabled()
+
+ PanelActionIconButton {
+ Layout.alignment: Qt.AlignHCenter
+ icon: "camera"
+ tooltipText: root.trSafe("panel.embedded-mirror.screenshot", "Take Screenshot")
+ enabled: root.embeddedMirrorInputActive() && !KDEConnect.adbScreenshotBusy
+ onClicked: root.takeMirrorScreenshot()
+ }
+
+ PanelActionIconButton {
+ Layout.alignment: Qt.AlignHCenter
+ icon: "video"
+ tooltipText: KDEConnect.adbScreenRecordingActive
+ ? root.trSafe("panel.embedded-mirror.record-stop", "Stop Recording")
+ : root.trSafe("panel.embedded-mirror.record-start", "Start Recording")
+ enabled: KDEConnect.adbScreenRecordingActive
+ || (root.embeddedMirrorInputActive() && !KDEConnect.adbScreenRecordingBusy)
+ active: KDEConnect.adbScreenRecordingActive
+ onClicked: root.toggleMirrorScreenRecording()
+ }
+
+ PanelActionIconButton {
+ Layout.alignment: Qt.AlignHCenter
+ icon: "moon"
+ tooltipText: root.trSafe("panel.embedded-mirror.keep-screen-on", "Keep Screen Awake")
+ enabled: (root.embeddedMirrorInputActive() && !root.keepScreenOnPending)
+ || root.keepScreenOnEnabled
+ active: root.keepScreenOnEnabled || root.keepScreenOnPending
+ onClicked: root.toggleKeepScreenOnWhilePanelOpen()
+ }
+
+ PanelActionIconButton {
+ Layout.alignment: Qt.AlignHCenter
+ icon: root.dimScreenEnabled ? "sun-dim" : "sun"
+ tooltipText: root.dimScreenEnabled
+ ? root.trSafe("panel.embedded-mirror.screen-restore", "Restore Screen Brightness")
+ : root.trSafe("panel.embedded-mirror.screen-dim", "Set Screen to Minimum Brightness")
+ enabled: root.embeddedMirrorInputActive() || root.dimScreenEnabled
+ active: root.dimScreenEnabled || root.dimScreenPending
+ onClicked: root.toggleMirrorScreenDim()
+ }
+ }
+
+ }
+
+ Rectangle {
+ id: embeddedMirrorStatusCard
+ Layout.fillWidth: true
+ Layout.fillHeight: false
+ Layout.preferredHeight: Math.min(
+ implicitHeight,
+ Math.max(
+ root.phoneSizeValue(104, 118, 132) * Style.uiScaleRatio,
+ phonePreviewContainer.height
+ - rightInfoColumn.Layout.topMargin
+ - deviceSummaryColumn.implicitHeight
+ - (mirrorUtilityCard.visible
+ ? (mirrorUtilityCard.implicitHeight + rightInfoColumn.spacing)
+ : 0)
+ - rightInfoColumn.spacing
+ )
+ )
+ Layout.maximumHeight: Layout.preferredHeight
+ Layout.topMargin: root.embeddedMirrorDrawerStatusVisible(phonePreview) ? Style.marginS : 0
+ visible: root.embeddedMirrorDrawerStatusVisible(phonePreview)
+ implicitHeight: Math.max(
+ drawerStatusContent.implicitHeight + (Style.marginM * 1.8),
+ root.phoneSizeValue(104, 118, 132) * Style.uiScaleRatio
+ )
+ radius: Style.radiusL
+ color: root.shellCardColor
+ border.width: Style.borderS
+ border.color: root.shellCardBorderColor
+ clip: true
+
+ ColumnLayout {
+ id: drawerStatusContent
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginXS
+
+ NText {
+ Layout.fillWidth: true
+ text: root.embeddedMirrorDrawerStatusTitle(phonePreview)
+ pointSize: Style.fontSizeS * root.phoneSizeValue(1.02, 1.1, 1.1)
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ visible: text !== ""
+ wrapMode: Text.WordWrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.embeddedMirrorDrawerStatusSubtitle(phonePreview)
+ pointSize: Style.fontSizeXS * root.phoneSizeValue(1.0, 1.06, 1.06)
+ color: root.shellSecondaryTextColor
+ visible: text !== ""
+ wrapMode: Text.WordWrap
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ visible: root.setupRequiredLoopbackCommandVisible()
+ color: root.shellNestedCardColor
+ radius: Style.radiusM
+ border.width: Style.borderS
+ border.color: root.shellNestedCardBorderColor
+ implicitHeight: drawerLoopbackCommandColumn.implicitHeight + (Style.marginM * 1.2)
+
+ ColumnLayout {
+ id: drawerLoopbackCommandColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginM * 0.9
+ spacing: Style.marginXS
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NIcon {
+ icon: "copy"
+ pointSize: Style.fontSizeM
+ color: root.shellAccentIconColor
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.trSafe("panel.setup-required.command-label", "Click to copy the loopback setup command")
+ pointSize: Style.fontSizeS
+ color: root.shellAccentTextColor
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.embeddedMirrorLoopbackSetupCommand
+ pointSize: Style.fontSizeXS
+ color: root.shellPrimaryTextColor
+ wrapMode: Text.WrapAnywhere
+ font.family: "monospace"
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.copyTextToClipboard(
+ root.embeddedMirrorLoopbackSetupCommand,
+ root.trSafe("panel.setup-required.command-copied", "Loopback setup command copied.")
+ );
+ }
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ visible: true
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ visible: !root.embeddedMirrorDrawerStatusVisible(phonePreview)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: noDevicePairedCard
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: implicitHeight
+ color: root.shellStageColor
+ radius: Style.radiusL
+ implicitHeight: noDevicePairedContent.implicitHeight + (Style.marginL * 2.4)
+
+ ColumnLayout {
+ id: noDevicePairedContent
+ anchors {
+ fill: parent
+ margins: Style.marginL * 1.2
+ }
+ spacing: Style.marginL * 1.1
+
+ RowLayout {
+ Layout.fillWidth: true
+ NText {
+ text: KDEConnect.mainDevice?.name || root.trSafe("panel.unknown", "Unknown")
+ pointSize: Style.fontSizeXXL
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ Layout.fillWidth: true
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: pairStateColumn.implicitHeight + (Style.marginL * 1.8)
+ color: root.shellCardColor
+ radius: Style.radiusL
+ border.width: Style.borderS
+ border.color: root.shellCardBorderColor
+
+ ColumnLayout {
+ id: pairStateColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginL * 1.1
+ spacing: Style.marginM
+
+ Item {
+ Layout.fillWidth: true
+ implicitHeight: pairHeader.implicitHeight
+
+ RowLayout {
+ id: pairHeader
+ anchors.centerIn: parent
+ spacing: Style.marginM
+
+ Rectangle {
+ width: 48 * Style.uiScaleRatio
+ height: width
+ radius: width / 2
+ color: KDEConnect.mainDevice.pairRequested ? root.shellAccentCardColor : root.shellIconChipColor
+ border.width: Style.borderS
+ border.color: KDEConnect.mainDevice.pairRequested ? root.shellAccentCardBorderColor : root.shellIconChipBorderColor
+
+ NIcon {
+ anchors.centerIn: parent
+ icon: KDEConnect.mainDevice.pairRequested ? "key" : "device-mobile"
+ pointSize: Style.fontSizeXL
+ color: KDEConnect.mainDevice.pairRequested ? root.shellAccentIconColor : root.shellIconChipFgColor
+ }
+ }
+
+ ColumnLayout {
+ spacing: Style.marginXXS
+
+ NText {
+ text: KDEConnect.mainDevice.pairRequested
+ ? root.trSafe("panel.pair-requested-title", "Pairing Request Sent")
+ : root.trSafe("panel.pair-needed-title", "Pairing Needed")
+ pointSize: Style.fontSizeL * 1.06
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ }
+
+ NText {
+ text: KDEConnect.mainDevice.pairRequested
+ ? root.trSafe("panel.pair-requested-subtitle", "Approve the request on the phone to restore controls.")
+ : root.trSafe("panel.pair-needed-subtitle", "KDE Connect reported this device as temporarily unpaired.")
+ pointSize: Style.fontSizeS * 1.02
+ color: root.shellSecondaryTextColor
+ }
+ }
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: KDEConnect.mainDevice.pairRequested
+ ? root.trSafe("panel.pair-requested", "Confirm the pairing request on the phone. The mirror and device actions will come back automatically after approval.")
+ : root.trSafe("panel.pair-description", "This device is temporarily reported as unpaired. Retry pairing here if KDE Connect did not recover on its own after reconnecting.")
+ color: root.shellSecondaryTextColor
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ NButton {
+ text: root.trSafe("panel.pair", "Pair with Device")
+ Layout.alignment: Qt.AlignHCenter
+ Layout.minimumWidth: 220 * Style.uiScaleRatio
+ enabled: !KDEConnect.mainDevice.pairRequested
+ icon: "key"
+ onClicked: {
+ KDEConnect.requestPairing(KDEConnect.mainDevice.id)
+ KDEConnect.mainDevice.pairRequested = true
+ KDEConnect.refreshDevices()
+ }
+ }
+
+ Rectangle {
+ Layout.alignment: Qt.AlignHCenter
+ visible: KDEConnect.mainDevice.pairRequested && String(KDEConnect.mainDevice.verificationKey || "").trim() !== ""
+ color: root.shellAccentCardColor
+ radius: Style.radiusM
+ border.width: Style.borderS
+ border.color: root.shellAccentCardBorderColor
+ implicitWidth: verificationRow.implicitWidth + (Style.marginM * 1.4)
+ implicitHeight: verificationRow.implicitHeight + (Style.marginS * 1.4)
+
+ RowLayout {
+ id: verificationRow
+ anchors.centerIn: parent
+ spacing: Style.marginS
+
+ NIcon {
+ icon: "key"
+ pointSize: Style.fontSizeL
+ color: root.shellAccentIconColor
+ }
+
+ NText {
+ text: KDEConnect.mainDevice.verificationKey
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: root.shellAccentTextColor
+ }
+ }
+ }
+
+ NBusyIndicator {
+ Layout.alignment: Qt.AlignHCenter
+ visible: KDEConnect.mainDevice.pairRequested
+ size: Style.baseWidgetSize * 0.5
+ running: KDEConnect.mainDevice.pairRequested
+ }
+
+ NText {
+ Layout.fillWidth: true
+ visible: KDEConnect.mainDevice.pairRequested
+ text: root.trSafe("panel.pair-waiting", "Waiting for the phone to accept the pairing request.")
+ pointSize: Style.fontSizeS
+ color: root.shellSecondaryTextColor
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: setupRequiredCard
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: implicitHeight
+ color: root.shellStageColor
+ radius: Style.radiusL
+ implicitHeight: setupRequiredContent.implicitHeight + (Style.marginL * 2.4)
+
+ ColumnLayout {
+ id: setupRequiredContent
+ anchors {
+ fill: parent
+ margins: Style.marginL * 1.2
+ }
+ spacing: Style.marginL * 1.1
+
+ NText {
+ text: root.trSafe("panel.setup-required.phone-name", "Android Phone")
+ pointSize: Style.fontSizeXXL
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ Layout.fillWidth: true
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: setupRequiredColumn.implicitHeight + (Style.marginL * 1.8)
+ color: root.shellCardColor
+ radius: Style.radiusL
+ border.width: Style.borderS
+ border.color: root.shellCardBorderColor
+
+ ColumnLayout {
+ id: setupRequiredColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginL * 1.1
+ spacing: Style.marginM
+
+ Item {
+ Layout.fillWidth: true
+ implicitHeight: setupRequiredHeader.implicitHeight
+
+ RowLayout {
+ id: setupRequiredHeader
+ anchors.centerIn: parent
+ spacing: Style.marginM
+
+ Rectangle {
+ width: 48 * Style.uiScaleRatio
+ height: width
+ radius: width / 2
+ color: root.shellIconChipColor
+ border.width: Style.borderS
+ border.color: root.shellIconChipBorderColor
+
+ NIcon {
+ anchors.centerIn: parent
+ icon: "device-mobile-off"
+ pointSize: Style.fontSizeXL
+ color: root.shellIconChipFgColor
+ }
+ }
+
+ ColumnLayout {
+ spacing: Style.marginXXS
+
+ NText {
+ text: root.trSafe("panel.setup-required.title", "Finish Setup to Connect")
+ pointSize: Style.fontSizeL * 1.06
+ font.weight: Style.fontWeightBold
+ color: root.shellPrimaryTextColor
+ }
+
+ NText {
+ text: root.trSafe("panel.setup-required.subtitle", "Link the phone first, then the mirror controls and status will appear here.")
+ pointSize: Style.fontSizeS * 1.02
+ color: root.shellSecondaryTextColor
+ wrapMode: Text.WordWrap
+ }
+ }
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.setupRequiredPairingStepText()
+ color: root.shellSecondaryTextColor
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.setupRequiredAdbStepText()
+ color: root.shellSecondaryTextColor
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.setupRequiredLoopbackStepText()
+ color: root.shellSecondaryTextColor
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ NButton {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.minimumWidth: 240 * Style.uiScaleRatio
+ visible: KDEConnect.mainDevice !== null && !KDEConnect.mainDevice.paired
+ enabled: !root.mainDevicePairingInProgress()
+ text: root.trSafe("panel.setup-required.pair-button", "Start KDE Connect Pairing")
+ icon: "key"
+ onClicked: root.triggerMainDevicePairing()
+ }
+
+ Rectangle {
+ Layout.alignment: Qt.AlignHCenter
+ visible: root.mainDevicePairingInProgress() && String(KDEConnect.mainDevice?.verificationKey || "").trim() !== ""
+ color: root.shellAccentCardColor
+ radius: Style.radiusM
+ border.width: Style.borderS
+ border.color: root.shellAccentCardBorderColor
+ implicitWidth: setupVerificationRow.implicitWidth + (Style.marginM * 1.4)
+ implicitHeight: setupVerificationRow.implicitHeight + (Style.marginS * 1.4)
+
+ RowLayout {
+ id: setupVerificationRow
+ anchors.centerIn: parent
+ spacing: Style.marginS
+
+ NIcon {
+ icon: "key"
+ pointSize: Style.fontSizeL
+ color: root.shellAccentIconColor
+ }
+
+ NText {
+ text: KDEConnect.mainDevice?.verificationKey || ""
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: root.shellAccentTextColor
+ }
+ }
+ }
+
+ NBusyIndicator {
+ Layout.alignment: Qt.AlignHCenter
+ visible: root.mainDevicePairingInProgress()
+ size: Style.baseWidgetSize * 0.5
+ running: root.mainDevicePairingInProgress()
+ }
+
+ NText {
+ Layout.fillWidth: true
+ visible: root.mainDevicePairingInProgress()
+ text: root.trSafe("panel.setup-required.pair-waiting", "Approve the KDE Connect pairing request on the phone to continue.")
+ color: root.shellSecondaryTextColor
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ Rectangle {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.fillWidth: true
+ visible: root.setupRequiredLoopbackCommandVisible()
+ color: root.shellNestedCardColor
+ radius: Style.radiusM
+ border.width: Style.borderS
+ border.color: root.shellNestedCardBorderColor
+ implicitHeight: loopbackCommandColumn.implicitHeight + (Style.marginM * 1.2)
+
+ ColumnLayout {
+ id: loopbackCommandColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginM * 0.9
+ spacing: Style.marginXS
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NIcon {
+ icon: "copy"
+ pointSize: Style.fontSizeM
+ color: root.shellAccentIconColor
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.trSafe("panel.setup-required.command-label", "Click to copy the loopback setup command")
+ pointSize: Style.fontSizeS
+ color: root.shellAccentTextColor
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: root.embeddedMirrorLoopbackSetupCommand
+ pointSize: Style.fontSizeXS
+ color: root.shellPrimaryTextColor
+ wrapMode: Text.WrapAnywhere
+ font.family: "monospace"
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.copyTextToClipboard(
+ root.embeddedMirrorLoopbackSetupCommand,
+ root.trSafe("panel.setup-required.command-copied", "Loopback setup command copied.")
+ );
+ }
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: noDevicesAvailableCard
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+
+ ColumnLayout {
+ id: emptyState
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ Item {
+ Layout.fillHeight: true
+ }
+
+ NIcon {
+ icon: "device-mobile-off"
+ pointSize: Style.fontSizeXXL * 1.5
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Item {}
+
+ NText {
+ text: pluginApi?.tr("panel.kdeconnect-error.no-devices")
+ pointSize: Style.fontSizeL
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignCenter
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ wrapMode: Text.WordWrap
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+
+
+ Component {
+ id: busctlNotFoundCard
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+
+ ColumnLayout {
+ id: emptyState
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ Item {
+ Layout.fillHeight: true
+ }
+
+ NIcon {
+ icon: "exclamation-circle"
+ pointSize: Style.fontSizeXXL * 1.5
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Item {}
+
+ NText {
+ text: pluginApi?.tr("panel.busctl-error.unavailable-title")
+ pointSize: Style.fontSizeL
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignCenter
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ NText {
+ text: pluginApi?.tr("panel.busctl-error.unavailable-desc")
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignCenter
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+
+ Component {
+ id: kdeConnectDaemonNotRunningCard
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+
+ ColumnLayout {
+ id: emptyState
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ Item {
+ Layout.fillHeight: true
+ }
+
+ NIcon {
+ icon: "exclamation-circle"
+ pointSize: Style.fontSizeXXL * 1.5
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Item {}
+
+ NText {
+ text: pluginApi?.tr("panel.kdeconnect-error.unavailable-title")
+ pointSize: Style.fontSizeL
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignCenter
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ NText {
+ text: pluginApi?.tr("panel.kdeconnect-error.unavailable-desc")
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignCenter
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+
+ Component {
+ id: deviceSwitcherCard
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+
+ NScrollView{
+ horizontalPolicy: ScrollBar.AlwaysOff
+ verticalPolicy: ScrollBar.AsNeeded
+ contentWidth: parent.width
+ reserveScrollbarSpace: false
+ gradientColor: Color.mSurface
+
+ ColumnLayout {
+ id: emptyState
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ Repeater {
+ model: KDEConnect.devices
+ Layout.fillWidth: true
+
+ NButton {
+ required property var modelData
+ text: modelData.name
+ Layout.fillWidth: true
+ backgroundColor: modelData.id === KDEConnect.mainDevice.id ? Color.mSecondary : Color.mPrimary
+
+ onClicked: {
+ KDEConnect.setMainDevice(modelData.id);
+ deviceSwitcherOpen = false;
+
+ pluginApi.pluginSettings.mainDeviceId = modelData.id;
+ pluginApi.saveSettings();
+ }
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Popup {
+ id: wirelessAdbPopup
+ parent: root
+ modal: true
+ dim: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+ anchors.centerIn: parent
+ width: Math.min(620 * Style.uiScaleRatio, root.width - (Style.marginL * 2))
+ height: Math.min(wirelessAdbContentColumn.implicitHeight + (padding * 2), root.height - (Style.marginL * 2))
+ padding: Style.marginL
+
+ onOpened: {
+ Qt.callLater(() => {
+ if (pairHostInput.inputItem) {
+ pairHostInput.inputItem.forceActiveFocus();
+ }
+ });
+ }
+
+ background: Rectangle {
+ color: Color.mSurface
+ radius: Style.radiusL
+ border.color: Color.mOutline
+ border.width: Style.borderM
+ }
+
+ contentItem: Flickable {
+ id: wirelessAdbFlickable
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ contentWidth: width
+ contentHeight: wirelessAdbContentColumn.implicitHeight
+ implicitHeight: Math.min(wirelessAdbContentColumn.implicitHeight, root.height - (Style.marginL * 4))
+
+ ScrollBar.vertical: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ }
+
+ ColumnLayout {
+ id: wirelessAdbContentColumn
+ width: wirelessAdbFlickable.width - Style.marginXS
+ spacing: Style.marginM
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.dialog-title", "Wireless ADB")
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ Layout.fillWidth: true
+ }
+
+ NIconButton {
+ icon: "close"
+ tooltipText: I18n.tr("common.close")
+ onClicked: wirelessAdbPopup.close()
+ }
+ }
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.dialog-description", "Pair once from Android's Wireless debugging screen, then connect with the adb port shown on the phone.")
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ implicitHeight: qrStep.implicitHeight + (Style.marginM * 2)
+
+ ColumnLayout {
+ id: qrStep
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.qr-step-title", "1. Pair with QR code")
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.qr-section-description", "On the phone, open Wireless debugging and choose Pair device with QR code, then scan this image.")
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ Rectangle {
+ Layout.preferredWidth: 176 * Style.uiScaleRatio
+ Layout.preferredHeight: 176 * Style.uiScaleRatio
+ radius: Style.radiusM
+ color: Color.mSurface
+ border.color: Qt.rgba(Color.mOutline.r, Color.mOutline.g, Color.mOutline.b, 0.5)
+ border.width: Style.borderS
+
+ Image {
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ source: root.wirelessAdbQrImageSource()
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ visible: source !== ""
+ }
+
+ NText {
+ anchors.centerIn: parent
+ width: parent.width - (Style.marginM * 2)
+ text: root.trSafe("panel.wireless-adb.qr-placeholder", "Tap Start QR to generate a pairing code.")
+ visible: root.wirelessAdbQrImageSource() === ""
+ color: Color.mOnSurfaceVariant
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.qr-helper-description", "The plugin will wait for the scan, pair automatically, then connect ADB and save the resolved host and port.")
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+
+ NButton {
+ text: KDEConnect.wirelessAdbBusy
+ ? root.trSafe("panel.wireless-adb.qr-waiting-button", "Waiting for scan...")
+ : (root.wirelessAdbQrImageSource() !== ""
+ ? root.trSafe("panel.wireless-adb.qr-refresh-button", "Refresh QR")
+ : root.trSafe("panel.wireless-adb.qr-button", "Start QR Pairing"))
+ icon: "qrcode"
+ enabled: !wirelessAdbQrEncodeProc.running && !KDEConnect.wirelessAdbBusy
+ onClicked: root.beginWirelessAdbQrPairing()
+ }
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.qr-footer-description", "Leave this popup open until the phone finishes the scan.")
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+ }
+
+ NTextInput {
+ id: pairHostInput
+ Layout.fillWidth: true
+ label: root.trSafe("panel.wireless-adb.host-label", "Phone IP")
+ placeholderText: "192.168.1.120"
+ text: root.wirelessAdbPairHost
+ onTextChanged: {
+ root.wirelessAdbPairHost = text;
+ root.wirelessAdbConnectHost = text;
+ }
+ onEditingFinished: root.persistWirelessAdbSettings()
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ implicitHeight: pairStep.implicitHeight + (Style.marginM * 2)
+
+ ColumnLayout {
+ id: pairStep
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.pair-step-title", "2. Pair with code")
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.pair-section-description", "On the phone, open Wireless debugging and choose Pair device with pairing code.")
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NTextInput {
+ Layout.preferredWidth: 150 * Style.uiScaleRatio
+ label: root.trSafe("panel.wireless-adb.pair-port-label", "Pair port")
+ placeholderText: "37099"
+ text: root.wirelessAdbPairPort
+ onTextChanged: root.wirelessAdbPairPort = text
+ onEditingFinished: root.persistWirelessAdbSettings()
+ }
+
+ NTextInput {
+ Layout.fillWidth: true
+ label: root.trSafe("panel.wireless-adb.pair-code-label", "Pairing code")
+ placeholderText: "123456"
+ text: root.wirelessAdbPairingCode
+ onTextChanged: root.wirelessAdbPairingCode = text
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NButton {
+ text: root.trSafe("panel.wireless-adb.pair-button", "Pair")
+ icon: "key"
+ enabled: !KDEConnect.wirelessAdbBusy
+ && (root.wirelessAdbPairHost || "").trim() !== ""
+ && (root.wirelessAdbPairPort || "").trim() !== ""
+ && (root.wirelessAdbPairingCode || "").trim() !== ""
+ onClicked: root.startWirelessAdbPairing()
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ implicitHeight: connectStep.implicitHeight + (Style.marginM * 2)
+
+ ColumnLayout {
+ id: connectStep
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.connect-step-title", "3. Connect after pairing")
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: root.trSafe("panel.wireless-adb.connect-section-description", "After pairing, use the adb port shown on the phone. The same phone IP above will be reused.")
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+
+ NTextInput {
+ Layout.fillWidth: true
+ label: root.trSafe("panel.wireless-adb.connect-port-label", "ADB port")
+ placeholderText: "43127"
+ text: root.wirelessAdbConnectPort
+ onTextChanged: root.wirelessAdbConnectPort = text
+ onEditingFinished: root.persistWirelessAdbSettings()
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ NText {
+ text: (root.wirelessAdbPairHost || "").trim() !== ""
+ ? (root.trSafe("panel.wireless-adb.host-label", "Phone IP") + ": " + root.wirelessAdbPairHost)
+ : ""
+ color: Color.mOnSurfaceVariant
+ Layout.fillWidth: true
+ visible: text !== ""
+ elide: Text.ElideRight
+ }
+
+ NButton {
+ text: root.trSafe("panel.wireless-adb.connect-button", "Connect")
+ icon: "plug-connected"
+ enabled: !KDEConnect.wirelessAdbBusy
+ && (((root.wirelessAdbConnectHost || "").trim() !== "") || ((root.wirelessAdbPairHost || "").trim() !== ""))
+ && (root.wirelessAdbConnectPort || "").trim() !== ""
+ onClicked: root.startWirelessAdbConnect()
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ visible: root.wirelessAdbStatusMessage !== "" || KDEConnect.wirelessAdbBusy
+ color: Color.mSurfaceVariant
+ radius: Style.radiusM
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ implicitHeight: statusColumn.implicitHeight + (Style.marginM * 2)
+
+ ColumnLayout {
+ id: statusColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginS
+
+ NText {
+ text: KDEConnect.wirelessAdbBusy
+ ? root.trSafe("panel.wireless-adb.running-status", "Running adb command...")
+ : root.trSafe("panel.wireless-adb.status-title", "Last result")
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ Layout.fillWidth: true
+ }
+
+ NText {
+ text: KDEConnect.wirelessAdbBusy
+ ? root.trSafe("panel.wireless-adb.running-description", "Keep this panel open until adb finishes.")
+ : root.wirelessAdbStatusMessage
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/androidconnect/PhoneDisplay.qml b/androidconnect/PhoneDisplay.qml
new file mode 100644
index 000000000..d805c191e
--- /dev/null
+++ b/androidconnect/PhoneDisplay.qml
@@ -0,0 +1,1125 @@
+import Qt5Compat.GraphicalEffects
+import QtMultimedia
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Layouts
+import Quickshell.Io
+import qs.Commons
+
+Rectangle {
+ id: phoneRoot
+
+ property bool showStatusOverlay: false
+ property string statusTitle: ""
+ property string statusSubtitle: ""
+ property bool busy: false
+ property bool mirrorFeedEnabled: false
+ property bool interactiveScreen: false
+ property string mirrorDeviceIdMatch: ""
+ property string mirrorDeviceDescriptionMatch: ""
+ property int mirrorContentWidth: 0
+ property int mirrorContentHeight: 0
+ property string mirrorFeedError: ""
+ property bool mediaDevicesReloadPending: false
+ property int mediaDevicesReloadDelayMs: 80
+ property bool mirrorFeedAttachDelayActive: false
+ property bool nativeProbeReady: false
+ property bool nativeProbePending: false
+ property bool nativeCameraRebindPending: false
+ property bool nativeZeroRectObserved: false
+ property bool nativeZeroRectRecoveryScheduled: false
+ property int nativeProbeRetryCount: 0
+ property double nativeAttachStartedAtMs: 0
+ property int nativeAttachRecoveryAttempts: 0
+ property double mirrorFeedLastFrameAtMs: 0
+ property bool mirrorFeedFrameLive: false
+ property int mirrorFeedFrameCount: 0
+ property bool mirrorFirstFrameLogged: false
+ property double scrcpyStartedAtMs: 0
+ property bool lastObservedFrameLive: false
+ property var debugLogQueue: []
+ property string debugAppendLine: ""
+ readonly property string debugLogPath: "/tmp/androidconnect-preview-debug.log"
+ readonly property real deviceArtWidth: 597
+ readonly property real deviceArtHeight: 1241
+ readonly property rect deviceArtCropRect: Qt.rect(586, 27, 597, 1241)
+ readonly property real screenInsetLeftRatio: 25 / deviceArtWidth
+ readonly property real screenInsetRightRatio: 34 / deviceArtWidth
+ readonly property real screenInsetTopRatio: 26 / deviceArtHeight
+ readonly property real screenInsetBottomRatio: 25 / deviceArtHeight
+ readonly property real scaleFactor: Math.min(width / deviceArtWidth, height / deviceArtHeight)
+ readonly property real frameShadowRadius: 34 * phoneRoot.scaleFactor
+ readonly property real statusCardRadius: 14 * phoneRoot.scaleFactor
+ readonly property real statusCardMargin: 10 * phoneRoot.scaleFactor
+ readonly property real statusCardSpacing: 6 * phoneRoot.scaleFactor
+ readonly property real contentAspectRatio: {
+ if (mirrorContentWidth > 0 && mirrorContentHeight > 0)
+ return mirrorContentWidth / mirrorContentHeight;
+
+ return screen.width / Math.max(1, screen.height);
+ }
+ readonly property real videoFrameWidth: Math.min(screen.width, screen.height * contentAspectRatio)
+ readonly property real videoFrameHeight: Math.min(screen.height, screen.width / Math.max(0.001, contentAspectRatio))
+ readonly property real videoFrameLocalX: phoneRect.x + screen.x + videoFrame.x
+ readonly property real videoFrameLocalY: phoneRect.y + screen.y + videoFrame.y
+ readonly property real videoFrameGlobalX: phoneRoot.mapToGlobal(videoFrameLocalX, videoFrameLocalY).x
+ readonly property real videoFrameGlobalY: phoneRoot.mapToGlobal(videoFrameLocalX, videoFrameLocalY).y
+ readonly property real videoFrameGlobalWidth: videoFrame.width
+ readonly property real videoFrameGlobalHeight: videoFrame.height
+ readonly property real screenRadius: 44.3 * phoneRoot.scaleFactor
+ readonly property real videoFrameRadius: 52 * phoneRoot.scaleFactor
+ readonly property color frameShadowColor: Qt.alpha(Color.mOutline, 0.26)
+ readonly property color screenIdleBaseColor: Qt.alpha(Color.mSurface, 0.94)
+ readonly property color screenIdleTopColor: Qt.alpha(Color.mSurfaceVariant, 0.96)
+ readonly property color screenIdleMidColor: Qt.alpha(Color.mSurface, 0.92)
+ readonly property color screenIdleBottomColor: Qt.alpha(Color.mPrimaryContainer, 0.52)
+ readonly property color overlayFadeTopColor: Qt.alpha(Color.mSurface, 0.08)
+ readonly property color overlayFadeMidColor: Qt.alpha(Color.mSurface, 0.32)
+ readonly property color overlayFadeBottomColor: Qt.alpha(Color.mSurface, 0.58)
+ readonly property color overlayCardColor: Qt.alpha(Color.mSurfaceVariant, 0.9)
+ readonly property color overlayCardBorderColor: Qt.alpha(Color.mOutline, 0.34)
+ readonly property color overlayTitleColor: Color.mOnSurface
+ readonly property color overlaySubtitleColor: Color.mOnSurfaceVariant
+ readonly property color homeIndicatorColor: Qt.alpha(Color.mOnSurface, 0.66)
+ readonly property string normalizedIdMatch: (mirrorDeviceIdMatch || "").trim().toLowerCase()
+ readonly property string normalizedDescriptionMatch: (mirrorDeviceDescriptionMatch || "").trim().toLowerCase()
+ readonly property string trimmedMirrorDevicePath: String(mirrorDeviceIdMatch || "").trim()
+ readonly property var mediaDevicesRef: mediaDevicesLoader.item
+ readonly property var mediaVideoInputs: (mediaDevicesRef && mediaDevicesRef.videoInputs) ? mediaDevicesRef.videoInputs : []
+ readonly property string availableVideoInputsSummary: videoInputsSummary(mediaVideoInputs)
+ readonly property var selectedVideoInput: {
+ const inputs = mediaVideoInputs || [];
+ return findMatchingVideoInput(inputs, normalizedDescriptionMatch, "description", true)
+ || findMatchingVideoInput(inputs, normalizedIdMatch, "id", true)
+ || findMatchingVideoInput(inputs, normalizedDescriptionMatch, "description", false)
+ || findMatchingVideoInput(inputs, normalizedIdMatch, "id", false)
+ || (normalizedDescriptionMatch !== "" ? findScrcpyVideoInput(inputs) : undefined)
+ || defaultVideoInput(inputs);
+ }
+ readonly property string selectedVideoInputSummary: videoInputSummary(selectedVideoInput)
+ readonly property bool mirrorFeedAvailable: hasVideoInput(selectedVideoInput)
+ readonly property bool shouldActivateMirrorCamera: mirrorFeedEnabled
+ && mirrorFeedAvailable
+ && nativeProbeReady
+ && !nativeCameraRebindPending
+ && !mirrorFeedAttachDelayActive
+ readonly property bool mirrorFeedHasRenderedFrame: mirrorFeedFrameCount > 0
+ readonly property bool mirrorFeedHasSourceRect: {
+ const rect = mirrorVideoOutput ? mirrorVideoOutput.sourceRect : null;
+ return Boolean(rect) && Number(rect.width || 0) > 0 && Number(rect.height || 0) > 0;
+ }
+ readonly property bool mirrorDisplayVisible: shouldActivateMirrorCamera
+ && mirrorFeedError === ""
+ && (mirrorFeedFrameLive || mirrorFeedHasRenderedFrame || mirrorFeedHasSourceRect)
+ readonly property string activeSourceRectSummary: {
+ const rect = mirrorVideoOutput ? mirrorVideoOutput.sourceRect : null;
+ const width = rect ? Math.round(Number(rect.width || 0)) : 0;
+ const height = rect ? Math.round(Number(rect.height || 0)) : 0;
+ return width > 0 && height > 0 ? (width + "x" + height) : "0x0";
+ }
+
+ signal clicked()
+ signal tapRequested(real x, real y)
+ signal swipeRequested(real x1, real y1, real x2, real y2, int durationMs)
+ signal scrollRequested(real x, real y, real deltaX, real deltaY)
+ signal textRequested(string text)
+ signal keyRequested(int keyCode)
+ signal homeRequested()
+ signal recentsRequested()
+
+ function hasVideoInput(input) {
+ return input !== undefined && input !== null && !input.isNull;
+ }
+
+ function videoInputSummary(input) {
+ if (!hasVideoInput(input))
+ return "none";
+
+ const description = String(input.description || "").trim();
+ const id = String(input.id || "").trim();
+ if (description !== "" && id !== "")
+ return description + " [" + id + "]";
+
+ return description !== "" ? description : id;
+ }
+
+ function videoInputsSummary(inputs) {
+ const list = inputs || [];
+ if (!list.length)
+ return "none";
+
+ const parts = [];
+ for (let i = 0; i < list.length; ++i)
+ parts.push(videoInputSummary(list[i]));
+
+ return parts.join(", ");
+ }
+
+ function findMatchingVideoInput(inputs, needle, fieldName, exact) {
+ if (needle === "")
+ return undefined;
+
+ for (let i = 0; i < inputs.length; ++i) {
+ const device = inputs[i];
+ const fieldValue = normalizeText(device ? device[fieldName] : "");
+ if ((exact && fieldValue === needle) || (!exact && fieldValue.indexOf(needle) !== -1))
+ return device;
+ }
+
+ return undefined;
+ }
+
+ function findScrcpyVideoInput(inputs) {
+ for (let i = 0; i < inputs.length; ++i) {
+ const device = inputs[i];
+ const description = normalizeText(device ? device.description : "");
+ const id = normalizeText(device ? device.id : "");
+ if (description.indexOf("scrcpy") !== -1 || id.indexOf("scrcpy") !== -1 || description.indexOf("loopback") !== -1)
+ return device;
+ }
+
+ return undefined;
+ }
+
+ function defaultVideoInput(inputs) {
+ if (normalizedIdMatch !== "" || normalizedDescriptionMatch !== "")
+ return undefined;
+
+ if (inputs.length === 1)
+ return inputs[0];
+
+ const defaultInput = mediaDevicesRef ? mediaDevicesRef.defaultVideoInput : null;
+ return hasVideoInput(defaultInput) ? defaultInput : undefined;
+ }
+
+ function normalizeText(value) {
+ return String(value || "").trim().toLowerCase();
+ }
+
+ function shellQuote(value) {
+ return "'" + String(value || "").replace(/'/g, "'\"'\"'") + "'";
+ }
+
+ function msSinceScrcpy() {
+ const startedAt = Number(scrcpyStartedAtMs || 0);
+ if (startedAt <= 0)
+ return -1;
+
+ return Math.max(0, Math.round(Date.now() - startedAt));
+ }
+
+ function msSinceAttach() {
+ const attachedAt = Number(nativeAttachStartedAtMs || 0);
+ if (attachedAt <= 0)
+ return -1;
+
+ return Math.max(0, Math.round(Date.now() - attachedAt));
+ }
+
+ function timingSuffix() {
+ return " tSinceScrcpy=" + msSinceScrcpy() + " tSinceAttach=" + msSinceAttach();
+ }
+
+ function debugLog(message) {
+ const timestamp = new Date().toISOString();
+ const line = timestamp + " " + String(message || "");
+ Logger.i("AndroidConnectPreview", line);
+ const queue = Array.isArray(debugLogQueue) ? debugLogQueue.slice() : [];
+ queue.push(line);
+ debugLogQueue = queue;
+ flushDebugLogQueue();
+ }
+
+ function flushDebugLogQueue() {
+ if (debugAppendProc.running)
+ return;
+
+ const queue = Array.isArray(debugLogQueue) ? debugLogQueue.slice() : [];
+ if (queue.length === 0)
+ return;
+
+ debugAppendLine = String(queue.shift() || "");
+ debugLogQueue = queue;
+ debugAppendProc.running = true;
+ }
+
+ function normalizeMirrorError(message) {
+ const text = String(message || "").trim();
+ if (text.indexOf("Device or resource busy") !== -1 || text.indexOf("Camera is in use") !== -1)
+ return "Loopback device is busy. Recreate v4l2loopback with exclusive_caps=0 so scrcpy can write and the panel can read it.";
+
+ return text;
+ }
+
+ function noteMirrorFrameDelivered() {
+ mirrorFeedLastFrameAtMs = Date.now();
+ mirrorFeedFrameLive = true;
+ mirrorFeedFrameCount += 1;
+ if (!mirrorFirstFrameLogged) {
+ mirrorFirstFrameLogged = true;
+ nativeZeroRectObserved = activeSourceRectSummary === "0x0";
+ debugLog("first frame sourceRect=" + activeSourceRectSummary + timingSuffix());
+ if (nativeZeroRectObserved
+ && shouldActivateMirrorCamera
+ && !nativeZeroRectRecoveryScheduled
+ && nativeAttachRecoveryAttempts === 0) {
+ nativeZeroRectRecoveryScheduled = true;
+ nativeZeroRectRecoveryTimer.restart();
+ }
+ }
+ }
+
+ function beginNativeAttachWindow(reason) {
+ if (!mirrorFeedEnabled)
+ return;
+
+ nativeAttachStartedAtMs = Date.now();
+ mirrorFeedAttachDelayActive = true;
+ mirrorFeedAttachDelayTimer.restart();
+ debugLog("attach begin reason=" + String(reason || "unspecified")
+ + " selectedInput=" + selectedVideoInputSummary
+ + timingSuffix());
+ }
+
+ function scheduleNativeCameraRebind(reason) {
+ if (!mirrorFeedEnabled || !mirrorFeedAvailable || nativeCameraRebindPending)
+ return;
+
+ nativeCameraRebindPending = true;
+ nativeZeroRectRecoveryScheduled = false;
+ resetMirrorState();
+ debugLog("rebind reason=" + String(reason || "unspecified") + timingSuffix());
+ nativeCameraRebindTimer.restart();
+ }
+
+ function resetMirrorState() {
+ mirrorFeedError = "";
+ mirrorFeedLastFrameAtMs = 0;
+ mirrorFeedFrameLive = false;
+ mirrorFeedFrameCount = 0;
+ mirrorFirstFrameLogged = false;
+ nativeZeroRectObserved = false;
+ }
+
+ function reloadMediaDevices(delayMs) {
+ if (mediaDevicesReloadPending)
+ return;
+
+ mediaDevicesReloadDelayMs = Math.max(80, Math.round(Number(delayMs || 0)) || 80);
+ mediaDevicesReloadPending = true;
+ mediaDevicesLoader.active = false;
+ mediaDevicesReloadCommitTimer.interval = mediaDevicesReloadDelayMs;
+ mediaDevicesReloadCommitTimer.restart();
+ }
+
+ function probeNativeLoopback() {
+ if (!mirrorFeedEnabled)
+ return;
+
+ nativeProbePending = true;
+ nativeProbeRetryCount = 0;
+ nativeAttachRecoveryAttempts = 0;
+ nativeAttachStartedAtMs = 0;
+ resetMirrorState();
+ reloadMediaDevices(80);
+ nativeProbeRetryTimer.interval = 120;
+ nativeProbeRetryTimer.restart();
+ }
+
+ height: parent ? parent.height : 235
+ width: parent ? parent.width : (height / deviceArtHeight) * deviceArtWidth
+ radius: 0
+ color: "transparent"
+ border.width: 0
+ border.color: "transparent"
+ clip: false
+
+ Component.onCompleted: {
+ debugResetProc.running = true;
+ }
+
+ onMirrorFeedEnabledChanged: {
+ mediaDevicesReloadPending = false;
+ mediaDevicesReloadDelayMs = 80;
+ mediaDevicesLoader.active = false;
+ nativeProbeReady = false;
+ nativeProbePending = false;
+ nativeCameraRebindPending = false;
+ nativeZeroRectObserved = false;
+ nativeZeroRectRecoveryScheduled = false;
+ nativeProbeRetryCount = 0;
+ nativeAttachStartedAtMs = 0;
+ nativeAttachRecoveryAttempts = 0;
+ mirrorFeedAttachDelayActive = mirrorFeedEnabled;
+ lastObservedFrameLive = false;
+ resetMirrorState();
+ debugLog("mirrorFeedEnabled=" + mirrorFeedEnabled
+ + " devicePath=" + trimmedMirrorDevicePath
+ + timingSuffix());
+ if (mirrorFeedEnabled) {
+ mirrorFeedAttachDelayTimer.restart();
+ } else {
+ mirrorFeedAttachDelayTimer.stop();
+ }
+ }
+
+ onSelectedVideoInputSummaryChanged: {
+ if (mirrorFeedEnabled)
+ debugLog("selectedInput=" + selectedVideoInputSummary
+ + " available=" + mirrorFeedAvailable
+ + timingSuffix());
+ }
+
+ onMirrorFeedAvailableChanged: {
+ if (!mirrorFeedEnabled)
+ return;
+
+ resetMirrorState();
+ if (mirrorFeedAvailable) {
+ if (nativeProbePending || !nativeProbeReady) {
+ nativeProbePending = false;
+ nativeProbeReady = true;
+ nativeProbeRetryCount = 0;
+ beginNativeAttachWindow("video-input-ready");
+ }
+ return;
+ }
+
+ nativeProbeReady = false;
+ if (nativeProbePending && nativeProbeRetryCount < 6) {
+ nativeProbeRetryCount += 1;
+ nativeProbeRetryTimer.interval = nativeProbeRetryCount <= 2 ? 100 : 180;
+ nativeProbeRetryTimer.restart();
+ }
+ }
+
+ onMirrorFeedErrorChanged: {
+ if (mirrorFeedEnabled && String(mirrorFeedError || "").trim() !== "")
+ debugLog("mirrorFeedError=" + String(mirrorFeedError || "") + timingSuffix());
+ }
+
+ onMirrorFeedFrameLiveChanged: {
+ if (lastObservedFrameLive && !mirrorFeedFrameLive) {
+ const lastDelta = mirrorFeedLastFrameAtMs > 0
+ ? Math.round(Date.now() - mirrorFeedLastFrameAtMs)
+ : -1;
+ debugLog("frameLive=false count=" + mirrorFeedFrameCount
+ + " dt=" + lastDelta
+ + timingSuffix());
+ }
+ lastObservedFrameLive = mirrorFeedFrameLive;
+ }
+
+ Loader {
+ id: mediaDevicesLoader
+
+ active: false
+ sourceComponent: mediaDevicesComponent
+ }
+
+ Component {
+ id: mediaDevicesComponent
+
+ MediaDevices {
+ }
+ }
+
+ Timer {
+ id: mediaDevicesReloadCommitTimer
+
+ interval: 80
+ repeat: false
+ onTriggered: {
+ mediaDevicesLoader.active = true;
+ phoneRoot.mediaDevicesReloadPending = false;
+ }
+ }
+
+ Timer {
+ id: mirrorFeedAttachDelayTimer
+
+ interval: 100
+ repeat: false
+ onTriggered: {
+ phoneRoot.mirrorFeedAttachDelayActive = false;
+ }
+ }
+
+ Timer {
+ id: nativeCameraRebindTimer
+
+ interval: 200
+ repeat: false
+ onTriggered: {
+ phoneRoot.nativeCameraRebindPending = false;
+ if (!phoneRoot.mirrorFeedEnabled || !phoneRoot.mirrorFeedAvailable)
+ return;
+
+ phoneRoot.beginNativeAttachWindow("camera-rebind");
+ }
+ }
+
+ Timer {
+ id: nativeZeroRectRecoveryTimer
+
+ interval: 200
+ repeat: false
+ onTriggered: {
+ if (!phoneRoot.shouldActivateMirrorCamera
+ || !phoneRoot.nativeZeroRectObserved
+ || phoneRoot.mirrorFeedHasSourceRect
+ || phoneRoot.nativeAttachRecoveryAttempts > 0) {
+ phoneRoot.nativeZeroRectRecoveryScheduled = false;
+ return;
+ }
+
+ phoneRoot.nativeAttachRecoveryAttempts = 1;
+ phoneRoot.scheduleNativeCameraRebind("zero-source-rect");
+ }
+ }
+
+ Timer {
+ id: mediaDevicesRetryTimer
+
+ interval: 1200
+ repeat: true
+ running: phoneRoot.mirrorFeedEnabled
+ && (phoneRoot.nativeProbePending || phoneRoot.nativeProbeReady)
+ && !phoneRoot.mirrorFeedAvailable
+ && !phoneRoot.mediaDevicesReloadPending
+ onTriggered: {
+ phoneRoot.reloadMediaDevices(120);
+ }
+ }
+
+ Timer {
+ id: nativeProbeRetryTimer
+
+ interval: 120
+ repeat: false
+ onTriggered: {
+ if (!phoneRoot.mirrorFeedEnabled
+ || !phoneRoot.nativeProbePending
+ || phoneRoot.mirrorFeedAvailable
+ || phoneRoot.mediaDevicesReloadPending)
+ return;
+
+ phoneRoot.reloadMediaDevices(phoneRoot.nativeProbeRetryCount <= 2 ? 100 : 180);
+ }
+ }
+
+ Timer {
+ id: mirrorFeedFrameWatchdog
+
+ interval: 450
+ repeat: true
+ running: phoneRoot.mirrorFeedEnabled
+ onTriggered: {
+ if (!phoneRoot.shouldActivateMirrorCamera) {
+ phoneRoot.mirrorFeedFrameLive = false;
+ return;
+ }
+
+ const lastFrameAt = Number(phoneRoot.mirrorFeedLastFrameAtMs || 0);
+ phoneRoot.mirrorFeedFrameLive = lastFrameAt > 0 && (Date.now() - lastFrameAt) <= 1200;
+ }
+ }
+
+ Timer {
+ id: nativeAttachRecoveryTimer
+
+ interval: 700
+ repeat: true
+ running: phoneRoot.mirrorFeedEnabled
+ onTriggered: {
+ if (!phoneRoot.shouldActivateMirrorCamera
+ || phoneRoot.mirrorFeedError !== ""
+ || phoneRoot.mirrorFeedHasSourceRect
+ || phoneRoot.nativeAttachStartedAtMs <= 0)
+ return;
+
+ const attachAgeMs = Date.now() - phoneRoot.nativeAttachStartedAtMs;
+ const recoveryDelayMs = phoneRoot.nativeZeroRectObserved ? 900 : 1400;
+ if (attachAgeMs < recoveryDelayMs || phoneRoot.nativeAttachRecoveryAttempts >= 2)
+ return;
+
+ phoneRoot.nativeAttachRecoveryAttempts += 1;
+ phoneRoot.debugLog("attach recovery attempt=" + phoneRoot.nativeAttachRecoveryAttempts
+ + " sourceRect=" + phoneRoot.activeSourceRectSummary
+ + " attachAgeMs=" + Math.round(attachAgeMs)
+ + phoneRoot.timingSuffix());
+
+ if (phoneRoot.nativeAttachRecoveryAttempts === 1) {
+ phoneRoot.scheduleNativeCameraRebind("stalled-first-frame");
+ return;
+ }
+
+ phoneRoot.nativeProbePending = true;
+ phoneRoot.nativeProbeRetryCount = 0;
+ phoneRoot.beginNativeAttachWindow("stalled-first-frame");
+ phoneRoot.reloadMediaDevices(120);
+ nativeProbeRetryTimer.interval = 120;
+ nativeProbeRetryTimer.restart();
+ }
+ }
+
+ CaptureSession {
+ id: mirrorCaptureSession
+
+ camera: phoneRoot.shouldActivateMirrorCamera ? mirrorCamera : null
+ videoOutput: phoneRoot.shouldActivateMirrorCamera ? mirrorVideoOutput.videoSink : null
+ }
+
+ Camera {
+ id: mirrorCamera
+
+ active: phoneRoot.shouldActivateMirrorCamera
+ onActiveChanged: {
+ phoneRoot.debugLog("camera active=" + active + phoneRoot.timingSuffix());
+ if (active) {
+ phoneRoot.resetMirrorState();
+ } else {
+ phoneRoot.mirrorFeedFrameLive = false;
+ }
+ }
+ onCameraDeviceChanged: {
+ phoneRoot.resetMirrorState();
+ }
+ onErrorOccurred: (error, errorString) => {
+ phoneRoot.mirrorFeedFrameLive = false;
+ phoneRoot.mirrorFeedError = phoneRoot.normalizeMirrorError(errorString !== "" ? errorString : ("camera error " + error));
+ phoneRoot.debugLog("camera error code=" + error
+ + " string=" + String(errorString || "")
+ + " normalized=" + phoneRoot.mirrorFeedError
+ + phoneRoot.timingSuffix());
+ }
+ }
+
+ Binding {
+ target: mirrorCamera
+ property: "cameraDevice"
+ when: phoneRoot.shouldActivateMirrorCamera
+ && phoneRoot.hasVideoInput(phoneRoot.selectedVideoInput)
+ value: phoneRoot.selectedVideoInput
+ }
+
+ Binding {
+ target: mirrorCamera
+ property: "cameraDevice"
+ when: !phoneRoot.shouldActivateMirrorCamera
+ value: null
+ }
+
+ Process {
+ id: debugResetProc
+
+ running: false
+ command: ["sh", "-lc",
+ "log=" + phoneRoot.shellQuote(phoneRoot.debugLogPath) + "; "
+ + ": > \"$log\"; "
+ + "{ "
+ + "printf '=== AndroidConnect preview debug session ===\\n'; "
+ + "printf 'banner time=%s pid=%s\\n' \"$(date -Iseconds 2>/dev/null)\" \"$$\"; "
+ + "if command -v v4l2-ctl >/dev/null 2>&1; then "
+ + " v4l2-ctl --list-devices 2>&1 | sed 's/^/banner /'; "
+ + " if [ -r /sys/module/v4l2loopback/parameters/exclusive_caps ]; then "
+ + " printf 'banner v4l2loopback.exclusive_caps=%s\\n' \"$(cat /sys/module/v4l2loopback/parameters/exclusive_caps)\"; "
+ + " fi; "
+ + "fi; "
+ + "} >> \"$log\" 2>&1"
+ ]
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: {
+ phoneRoot.debugLog("ready devicePath=" + trimmedMirrorDevicePath
+ + " descriptionMatch=" + String(mirrorDeviceDescriptionMatch || ""));
+ }
+ }
+
+ Process {
+ id: debugAppendProc
+
+ running: false
+ command: ["sh", "-lc",
+ "line=" + phoneRoot.shellQuote(phoneRoot.debugAppendLine)
+ + "; printf '%s\\n' \"$line\" >> " + phoneRoot.shellQuote(phoneRoot.debugLogPath)
+ ]
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: phoneRoot.flushDebugLogQueue()
+ }
+
+ RectangularShadow {
+ anchors.fill: phoneRect
+ radius: phoneRoot.frameShadowRadius
+ blur: 24
+ spread: 0.08
+ color: phoneRoot.frameShadowColor
+ }
+
+ Item {
+ id: phoneRect
+
+ anchors.fill: parent
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: !phoneRoot.interactiveScreen
+ onClicked: phoneRoot.clicked()
+ }
+
+ Image {
+ id: phoneFrameImage
+
+ anchors.fill: parent
+ source: "Celu.png"
+ sourceClipRect: phoneRoot.deviceArtCropRect
+ fillMode: Image.Stretch
+ smooth: true
+ mipmap: true
+ }
+
+ Rectangle {
+ id: screen
+
+ radius: phoneRoot.screenRadius
+ color: phoneRoot.screenIdleBaseColor
+ antialiasing: true
+ clip: true
+ layer.enabled: !phoneRoot.mirrorDisplayVisible
+
+ anchors {
+ fill: parent
+ leftMargin: phoneRect.width * phoneRoot.screenInsetLeftRatio
+ rightMargin: phoneRect.width * phoneRoot.screenInsetRightRatio
+ topMargin: phoneRect.height * phoneRoot.screenInsetTopRatio
+ bottomMargin: phoneRect.height * phoneRoot.screenInsetBottomRatio
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ opacity: phoneRoot.mirrorDisplayVisible ? 0 : 1
+
+ gradient: Gradient {
+ GradientStop {
+ position: 0
+ color: phoneRoot.screenIdleTopColor
+ }
+
+ GradientStop {
+ position: 0.55
+ color: phoneRoot.screenIdleMidColor
+ }
+
+ GradientStop {
+ position: 1
+ color: phoneRoot.screenIdleBottomColor
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 220
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ Rectangle {
+ id: videoFrame
+
+ anchors.centerIn: parent
+ width: phoneRoot.videoFrameWidth
+ height: phoneRoot.videoFrameHeight
+ radius: phoneRoot.videoFrameRadius
+ color: "transparent"
+ antialiasing: true
+ visible: phoneRoot.mirrorFeedEnabled
+ clip: true
+ layer.enabled: videoFrame.visible && mirrorVideoReveal.visible
+
+ Rectangle {
+ id: videoFrameBed
+
+ anchors.fill: parent
+ color: phoneRoot.screenIdleBaseColor
+ opacity: mirrorVideoReveal.opacity
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 180
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ Item {
+ id: mirrorVideoReveal
+
+ anchors.fill: parent
+ visible: phoneRoot.shouldActivateMirrorCamera || opacity > 0.001
+ opacity: phoneRoot.mirrorDisplayVisible ? 1 : 0
+ scale: phoneRoot.mirrorDisplayVisible ? 1 : 0.9875
+ y: phoneRoot.mirrorDisplayVisible ? 0 : (8 * phoneRoot.scaleFactor)
+ transformOrigin: Item.Center
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 220
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: 280
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Behavior on y {
+ NumberAnimation {
+ duration: 280
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ VideoOutput {
+ id: mirrorVideoOutput
+
+ anchors.fill: parent
+ visible: phoneRoot.shouldActivateMirrorCamera || parent.opacity > 0.001
+ fillMode: VideoOutput.Stretch
+ onSourceRectChanged: {
+ const isZero = sourceRect.width <= 0 || sourceRect.height <= 0;
+ if (!isZero) {
+ phoneRoot.nativeAttachStartedAtMs = 0;
+ phoneRoot.nativeZeroRectRecoveryScheduled = false;
+ phoneRoot.debugLog("sourceRect=" + phoneRoot.activeSourceRectSummary + phoneRoot.timingSuffix());
+ } else if (phoneRoot.shouldActivateMirrorCamera
+ && phoneRoot.nativeZeroRectObserved
+ && !phoneRoot.nativeZeroRectRecoveryScheduled
+ && phoneRoot.nativeAttachRecoveryAttempts === 0) {
+ phoneRoot.nativeZeroRectRecoveryScheduled = true;
+ nativeZeroRectRecoveryTimer.restart();
+ }
+ }
+ }
+ }
+
+ Connections {
+ function onVideoFrameChanged(frame) {
+ phoneRoot.noteMirrorFrameDelivered();
+ }
+
+ target: mirrorVideoOutput.videoSink
+ enabled: target !== null && target !== undefined
+ }
+
+ MouseArea {
+ id: touchSurface
+
+ property real startXNorm: 0
+ property real startYNorm: 0
+ property real endXNorm: 0
+ property real endYNorm: 0
+ property real startLocalX: 0
+ property real startLocalY: 0
+ property real wheelXNorm: 0.5
+ property real wheelYNorm: 0.5
+ property real wheelAccumX: 0
+ property real wheelAccumY: 0
+ property bool moved: false
+ property double pressTimestamp: 0
+ property int activeButton: Qt.NoButton
+
+ function clampNorm(value, maxValue) {
+ if (maxValue <= 0)
+ return 0;
+
+ return Math.max(0, Math.min(1, value / maxValue));
+ }
+
+ function forwardSpecialKey(key) {
+ switch (key) {
+ case Qt.Key_Backspace:
+ phoneRoot.keyRequested(67);
+ return true;
+ case Qt.Key_Return:
+ case Qt.Key_Enter:
+ phoneRoot.keyRequested(66);
+ return true;
+ case Qt.Key_Tab:
+ phoneRoot.keyRequested(61);
+ return true;
+ case Qt.Key_Delete:
+ phoneRoot.keyRequested(112);
+ return true;
+ case Qt.Key_Home:
+ phoneRoot.homeRequested();
+ return true;
+ case Qt.Key_Escape:
+ phoneRoot.keyRequested(111);
+ return true;
+ case Qt.Key_Left:
+ phoneRoot.keyRequested(21);
+ return true;
+ case Qt.Key_Right:
+ phoneRoot.keyRequested(22);
+ return true;
+ case Qt.Key_Up:
+ phoneRoot.keyRequested(19);
+ return true;
+ case Qt.Key_Down:
+ phoneRoot.keyRequested(20);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ anchors.fill: parent
+ enabled: phoneRoot.interactiveScreen
+ hoverEnabled: true
+ activeFocusOnTab: phoneRoot.interactiveScreen
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+ Keys.priority: Keys.BeforeItem
+ Keys.onPressed: (event) => {
+ if (!phoneRoot.interactiveScreen)
+ return;
+
+ let handled = forwardSpecialKey(event.key);
+ const hasCommandModifier = (event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)) !== 0;
+ if (!handled && !hasCommandModifier) {
+ const text = String(event.text || "");
+ if (text !== "" && text >= " ") {
+ phoneRoot.textRequested(text);
+ handled = true;
+ }
+ }
+ if (handled)
+ event.accepted = true;
+ }
+ onPressed: (mouse) => {
+ touchSurface.forceActiveFocus();
+ activeButton = mouse.button;
+ if (mouse.button === Qt.RightButton) {
+ phoneRoot.keyRequested(4);
+ return;
+ }
+ if (mouse.button === Qt.MiddleButton) {
+ phoneRoot.recentsRequested();
+ return;
+ }
+ startLocalX = mouse.x;
+ startLocalY = mouse.y;
+ startXNorm = clampNorm(mouse.x, width);
+ startYNorm = clampNorm(mouse.y, height);
+ endXNorm = startXNorm;
+ endYNorm = startYNorm;
+ moved = false;
+ pressTimestamp = Date.now();
+ }
+ onPositionChanged: (mouse) => {
+ if (activeButton !== Qt.LeftButton || !(mouse.buttons & Qt.LeftButton))
+ return;
+
+ endXNorm = clampNorm(mouse.x, width);
+ endYNorm = clampNorm(mouse.y, height);
+ if (!moved)
+ moved = Math.abs(mouse.x - startLocalX) > 8 || Math.abs(mouse.y - startLocalY) > 8;
+ }
+ onReleased: (mouse) => {
+ if (activeButton !== Qt.LeftButton) {
+ activeButton = Qt.NoButton;
+ return;
+ }
+ const releaseXNorm = clampNorm(mouse.x, width);
+ const releaseYNorm = clampNorm(mouse.y, height);
+ const durationMs = Math.max(80, Math.min(1200, Math.round(Date.now() - pressTimestamp)));
+ if (moved)
+ phoneRoot.swipeRequested(startXNorm, startYNorm, releaseXNorm, releaseYNorm, durationMs);
+ else
+ phoneRoot.tapRequested(releaseXNorm, releaseYNorm);
+ activeButton = Qt.NoButton;
+ }
+ onCanceled: {
+ activeButton = Qt.NoButton;
+ }
+ onWheel: (wheel) => {
+ if (!phoneRoot.interactiveScreen)
+ return;
+
+ const rawDeltaX = wheel.pixelDelta.x !== 0 ? wheel.pixelDelta.x / 24 : wheel.angleDelta.x / 120;
+ const rawDeltaY = wheel.pixelDelta.y !== 0 ? wheel.pixelDelta.y / 24 : wheel.angleDelta.y / 120;
+ if (rawDeltaX === 0 && rawDeltaY === 0)
+ return;
+
+ wheelXNorm = clampNorm(wheel.x, width);
+ wheelYNorm = clampNorm(wheel.y, height);
+ wheelAccumX += rawDeltaX;
+ wheelAccumY += rawDeltaY;
+ wheelDispatchTimer.restart();
+ wheel.accepted = true;
+ }
+
+ Timer {
+ id: wheelDispatchTimer
+
+ interval: 40
+ repeat: false
+ onTriggered: {
+ if (touchSurface.wheelAccumX === 0 && touchSurface.wheelAccumY === 0)
+ return;
+
+ phoneRoot.scrollRequested(touchSurface.wheelXNorm, touchSurface.wheelYNorm, touchSurface.wheelAccumX, touchSurface.wheelAccumY);
+ touchSurface.wheelAccumX = 0;
+ touchSurface.wheelAccumY = 0;
+ }
+ }
+ }
+
+ layer.effect: OpacityMask {
+ maskSource: Rectangle {
+ width: videoFrame.width
+ height: videoFrame.height
+ radius: videoFrame.radius
+ antialiasing: true
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ visible: phoneRoot.showStatusOverlay && (phoneRoot.statusTitle !== "" || phoneRoot.statusSubtitle !== "" || phoneRoot.busy)
+ opacity: visible ? 1 : 0
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: visible && !phoneRoot.interactiveScreen && !phoneRoot.busy
+ hoverEnabled: enabled
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: phoneRoot.clicked()
+ }
+
+ Rectangle {
+ anchors.fill: parent
+
+ gradient: Gradient {
+ GradientStop {
+ position: 0
+ color: phoneRoot.overlayFadeTopColor
+ }
+
+ GradientStop {
+ position: 0.55
+ color: phoneRoot.overlayFadeMidColor
+ }
+
+ GradientStop {
+ position: 1
+ color: phoneRoot.overlayFadeBottomColor
+ }
+ }
+ }
+
+ Rectangle {
+ id: statusCard
+
+ anchors.centerIn: parent
+ width: parent.width - (22 * phoneRoot.scaleFactor)
+ implicitHeight: statusContent.implicitHeight + (20 * phoneRoot.scaleFactor)
+ radius: phoneRoot.statusCardRadius
+ color: phoneRoot.overlayCardColor
+ border.width: Math.max(1, Math.round(1 * phoneRoot.scaleFactor))
+ border.color: phoneRoot.overlayCardBorderColor
+
+ ColumnLayout {
+ id: statusContent
+
+ anchors.fill: parent
+ anchors.margins: phoneRoot.statusCardMargin
+ spacing: phoneRoot.statusCardSpacing
+
+ BusyIndicator {
+ Layout.alignment: Qt.AlignHCenter
+ running: phoneRoot.busy
+ visible: phoneRoot.busy
+ implicitWidth: 18 * phoneRoot.scaleFactor
+ implicitHeight: 18 * phoneRoot.scaleFactor
+ }
+
+ Text {
+ visible: phoneRoot.statusTitle !== ""
+ text: phoneRoot.statusTitle
+ color: phoneRoot.overlayTitleColor
+ textFormat: Text.PlainText
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.Wrap
+ font.pixelSize: Math.max(11, Math.round(18 * phoneRoot.scaleFactor))
+ font.weight: Font.DemiBold
+ lineHeight: 1.08
+ lineHeightMode: Text.ProportionalHeight
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ }
+
+ Text {
+ visible: phoneRoot.statusSubtitle !== ""
+ text: phoneRoot.statusSubtitle
+ color: phoneRoot.overlaySubtitleColor
+ textFormat: Text.PlainText
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.Wrap
+ font.pixelSize: Math.max(10, Math.round(14 * phoneRoot.scaleFactor))
+ lineHeight: 1.14
+ lineHeightMode: Text.ProportionalHeight
+ maximumLineCount: 5
+ elide: Text.ElideRight
+ Layout.topMargin: phoneRoot.statusTitle !== "" ? 4 * phoneRoot.scaleFactor : 0
+ Layout.fillWidth: true
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 120
+ }
+ }
+ }
+
+ layer.effect: OpacityMask {
+ maskSource: Rectangle {
+ width: screen.width
+ height: screen.height
+ radius: screen.radius
+ antialiasing: true
+ }
+ }
+ }
+
+ Rectangle {
+ width: 183 * phoneRoot.scaleFactor
+ height: 4.8 * phoneRoot.scaleFactor
+ radius: height / 2
+ color: phoneRoot.homeIndicatorColor
+ opacity: 0.66
+ visible: true
+
+ anchors {
+ horizontalCenter: parent.horizontalCenter
+ bottom: parent.bottom
+ bottomMargin: 35 * phoneRoot.scaleFactor
+ }
+ }
+ }
+}
diff --git a/androidconnect/README.md b/androidconnect/README.md
new file mode 100644
index 000000000..12f11d0f1
--- /dev/null
+++ b/androidconnect/README.md
@@ -0,0 +1,266 @@
+# AndroidConnect
+
+`AndroidConnect` is a Noctalia plugin for Android device status, quick actions, file transfer, and embedded `scrcpy` control directly inside the panel.
+
+This project is built on top of the original Noctalia `kde-connect` plugin. Credit and thanks to the original Noctalia KDE Connect plugin developers for the base plugin and architecture this work extends.
+
+Upstream base project:
+- https://github.com/WerWolv/noctalia-kde-connect
+
+Project repository:
+- https://github.com/demencia89/noctalia-shell-androidconnect-plugin
+
+## Screenshots
+
+### Plugin Preview
+
+
+
+### Panel Overview
+
+
+
+### Lock Screen View
+
+
+
+### Panel Close-up
+
+
+
+## Current Status
+
+Current plugin version: `1.4.0`
+
+The embedded mirror uses a single live feed path:
+
+- `scrcpy` writes into `v4l2loopback`
+- Qt Multimedia reads that loopback device inside the panel
+- There is no second mirror backend in the normal path
+
+Current behavior:
+- Embedded mirror launches automatically when the panel is ready, typically within about one second of `scrcpy` starting
+- Audio can be toggled from the panel header
+- Android nav buttons stay visible below the phone preview
+- Screenshot and screen recording actions are available from the right-side utility row
+- Keep-screen-awake is available from the utility row while the panel is open
+- Status and error messages stay hidden during the initial grace period, then appear only if the feed or input path is still not ready
+- Opening the panel while `scrcpy` is already connected sends unlock-only, not Home
+- Header brand badges use logo assets where available and fall back to icons otherwise
+- First-run cold-start reliability is fixed in `1.4.0`: the root cause was `v4l2loopback` advertising the scrcpy device as `V4L2_CAP_VIDEO_OUTPUT` at process start when created with `exclusive_caps=1`, which caused Qt Multimedia to filter it out and never re-enumerate. Using `exclusive_caps=0` on the scrcpy loopback resolves it
+
+## Features
+
+- KDE Connect device list, state, battery, signal, and notification summary
+- Wake device, browse files, send files, and ring phone from the panel
+- Embedded in-panel Android mirror
+- Live V4L2 feed for the embedded mirror
+- Optional embedded audio toggle, off by default
+- ADB tap, swipe, text, key, and Android navigation input
+- In-panel utility actions for screenshot, screen recording, and keep-screen-awake
+- Wireless ADB pairing and reconnect helpers
+- Existing plugin toasts are mirrored into notification history
+- Screenshot and screen recording save notifications include a link to the output folder
+
+## Dependencies
+
+Required for the base plugin:
+- Noctalia `>= 4.4.0`
+- KDE Connect desktop app and a running `kdeconnectd`
+- `busctl` from `systemd`
+
+Required for mirror and Android input features:
+- `scrcpy`
+- `adb` from Android platform-tools
+- Qt Multimedia runtime for your distro, for example `qt6-multimedia`
+
+Required for the embedded live feed:
+- `v4l2loopback`
+- A loopback device such as `/dev/video10`
+- A loopback label visible to Qt Multimedia, for example `scrcpy-panel`
+
+Required for some optional features:
+- `qrencode` for Wireless ADB QR pairing
+- `sshfs` and FUSE support for the Browse Files action
+
+Recommended:
+- `avahi-browse` for more reliable Wireless ADB service discovery
+
+Not versioned in this repository:
+- `settings.json` is local user state and should stay untracked
+
+## Install
+
+1. Copy this plugin directory into your Noctalia plugins directory.
+ Example: `~/.config/noctalia/plugins/androidconnect`
+2. Reload Noctalia or restart the shell so it picks up the plugin.
+3. Enable the plugin in Noctalia.
+4. Make sure KDE Connect is installed on both the desktop and phone, then pair the phone normally.
+
+## First-Time Setup
+
+If you want the default experience, which is embedded `scrcpy` inside the panel:
+
+1. Install `scrcpy`, `adb`, Qt Multimedia, and `v4l2loopback`.
+2. On the phone, enable Developer options and USB debugging.
+3. Connect the phone over USB once, unlock it, and accept the USB debugging prompt for this computer.
+4. Create the V4L2 loopback device used by the embedded feed.
+5. Open the panel.
+
+If ADB or the loopback feed is not ready, the plugin stays in a setup or error state and tells you what is missing instead of launching a broken mirror session.
+
+## Base Setup
+
+If you only want device status and KDE Connect actions:
+
+1. Confirm `kdeconnectd` is running.
+2. Enable the relevant KDE Connect phone-side plugins for battery, notifications, browse files, and remote actions.
+
+## Embedded Mirror Setup
+
+If you want the phone rendered inside the panel:
+
+1. Create a V4L2 loopback device with **`exclusive_caps=0`**. This is required — see the note below.
+ Example for a one-shot modprobe:
+
+```bash
+sudo modprobe -r v4l2loopback 2>/dev/null || true
+sudo modprobe v4l2loopback devices=1 video_nr=10 card_label=scrcpy-panel exclusive_caps=0 max_width=960 max_height=2160
+```
+
+To make it persist across reboots, add a config under `/etc/modprobe.d/`, for example `/etc/modprobe.d/v4l2loopback.conf`:
+
+```text
+options v4l2loopback video_nr=10 card_label="scrcpy-panel" exclusive_caps=0
+```
+
+If you already have other loopback devices (for example OBS's virtual camera), combine them into a single `options` line with comma-separated values per device, and make sure every entry in the `exclusive_caps` list is `0`:
+
+```text
+options v4l2loopback video_nr=0,10 card_label="OBS Virtual Camera,scrcpy-panel" exclusive_caps=0,0
+```
+
+After editing the config, reload the module:
+
+```bash
+sudo modprobe -r v4l2loopback && sudo modprobe v4l2loopback
+```
+
+2. Confirm `/dev/video10` exists and `scrcpy-panel` is visible to Qt Multimedia.
+3. Open the panel and wait for the embedded mirror to start. It should appear roughly one second after `scrcpy` launches.
+4. Use the speaker button in the header if you want embedded audio.
+
+### Why `exclusive_caps=0` is required
+
+With `exclusive_caps=1`, a `v4l2loopback` device advertises `V4L2_CAP_VIDEO_OUTPUT` when no consumer is attached and only flips to `V4L2_CAP_VIDEO_CAPTURE` once `scrcpy` starts writing. Qt Multimedia enumerates video-capture devices once at process startup and caches the result. If the panel opens before `scrcpy` writes, Qt sees the loopback as an output-only device, filters it out, and never re-enumerates it — the embedded mirror then stays black for the lifetime of the shell and no amount of panel reloading recovers it.
+
+With `exclusive_caps=0`, the loopback advertises both `CAPTURE` and `OUTPUT` unconditionally, so Qt enumerates it correctly at startup regardless of whether `scrcpy` has started yet. This is the only supported configuration.
+
+Notes:
+- If the phone is already mirrored when you open the plugin, AndroidConnect sends unlock-only and does not send Home.
+
+## Panel Controls
+
+### Header Actions
+
+- Device switcher when more than one phone is available
+- Phone size toggle
+- Embedded audio toggle
+- Wireless ADB tools
+- Browse files
+- Send file
+- Find phone
+
+### Mirror Navigation Row
+
+- `Back`
+- `Home`
+- `Recents`
+
+Mouse and keyboard shortcuts:
+- Right click on the phone view sends `Back`
+- Middle click on the phone view sends `Recents`
+- `Home` key sends `Home`
+- Arrow keys, Enter, Tab, Escape, Delete, and Backspace are forwarded to Android when the phone view is focused
+- Text typed into the focused phone view is sent to Android input
+
+### Utility Actions
+
+These appear in the utility action row under battery, network, and signal:
+
+- `Take Screenshot`
+- `Start / Stop Recording`
+- `Keep Screen Awake`
+
+Saved media locations:
+- Screenshots: `~/Pictures/AndroidConnect`
+- Screen recordings: `~/Videos/AndroidConnect`
+
+When a screenshot or recording finishes, AndroidConnect adds the same event to notification history and includes a link to open the output folder.
+
+## Wireless ADB Setup
+
+Wireless ADB is optional, but it improves embedded input when USB is not available.
+
+1. Open Android's `Wireless debugging` screen.
+2. Use either:
+ - `Pair with QR code`
+ - `Pair with code`
+3. Open the Wi-Fi button in the panel header.
+4. After pairing, connect using the ADB port shown on the phone.
+
+The plugin remembers the last successful host and port for later reconnects.
+
+Notes:
+- Wireless ADB is optional. USB ADB is still the simplest and most reliable first setup path.
+
+## Browse Files Notes
+
+The Browse Files action depends on KDE Connect's SFTP support.
+
+If it fails:
+- Check that the KDE Connect SFTP feature is enabled on the phone.
+- Make sure `sshfs` and FUSE support are installed.
+- If your file manager is sandboxed, it may not be able to access the mounted path.
+
+## Troubleshooting
+
+## Known Issues
+
+- The embedded screen can occasionally appear glitchy or partially broken after a device mode change. Closing the plugin and opening it again usually fixes it. If not, restart the shell.
+
+### Black Screen In The Embedded Mirror
+
+Most black-screen reports trace back to a `v4l2loopback` configuration issue. Check the following first:
+
+- `scrcpy`, `adb`, and Qt Multimedia are installed
+- `/dev/video10` exists
+- The loopback label is visible as `scrcpy-panel`
+- **`v4l2loopback` was created with `exclusive_caps=0`** — see the "Embedded Mirror Setup" section for why this matters and how to fix it
+
+Quick diagnostic commands:
+
+```bash
+# Confirm the scrcpy-panel device is present and Qt-visible
+v4l2-ctl --list-devices
+cat /sys/module/v4l2loopback/parameters/exclusive_caps # every entry should be "N"
+
+# Inspect the current format on the scrcpy loopback
+v4l2-ctl --device=/dev/video10 --all
+```
+
+If any `exclusive_caps` entry is `Y`, follow the "Embedded Mirror Setup" steps to recreate the loopback devices with `exclusive_caps=0`.
+
+AndroidConnect also writes mirror diagnostics to:
+
+```text
+/tmp/androidconnect-preview-debug.log
+```
+
+The log starts with a banner showing the current `v4l2-ctl --list-devices` output and the module's `exclusive_caps` parameter, which is usually enough to diagnose loopback problems at a glance.
+
+## Development Notes
+
+- This repository is intended to host the plugin in a downloadable state for other users.
+- Local machine state should not be committed.
+- The plugin still uses KDE Connect as its transport and device integration backend. `AndroidConnect` is a renamed and extended plugin package built on top of that foundation.
diff --git a/androidconnect/Services/KDEConnect.qml b/androidconnect/Services/KDEConnect.qml
new file mode 100644
index 000000000..bcc96ed8a
--- /dev/null
+++ b/androidconnect/Services/KDEConnect.qml
@@ -0,0 +1,2241 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.System
+import qs.Services.UI
+
+QtObject {
+ id: root
+
+ property list devices: []
+ property bool daemonAvailable: false
+ property int pendingDeviceCount: 0
+ property list pendingDevices: []
+ property bool deviceRefreshInProgress: false
+ property int deviceRefreshGeneration: 0
+ property var pairedStateGraceTimestamps: ({})
+ readonly property int pairedStateGraceMs: 45000
+
+ property var mainDevice: null
+ property string mainDeviceId: ""
+ property string busctlCmd: ""
+ readonly property string usbSelectionSentinel: "__NOCTALIA_USB__"
+ property bool scrcpyLaunching: false
+ property bool scrcpyStopRequested: false
+ property var scrcpyCommandArgs: []
+ property var scrcpyPendingCommandArgs: []
+ property string scrcpyLaunchError: ""
+ property string scrcpyLastStderr: ""
+ property string scrcpyDeviceId: ""
+ property string scrcpyFeedDevicePath: ""
+ property string scrcpyActiveSerial: ""
+ property string scrcpyCleanupFeedDevicePath: ""
+ readonly property bool scrcpyRunning: scrcpySessionProc.running
+ property bool wirelessAdbBusy: false
+ property var wirelessAdbCommandArgs: []
+ property string wirelessAdbLastStdout: ""
+ property string wirelessAdbLastStderr: ""
+ property string adbDevicesStdout: ""
+ property string adbDevicesStderr: ""
+ property int adbDevicesExitCode: 0
+ property var adbDeviceStates: ({})
+ property var adbConnectedSerials: []
+ property bool adbHasUsbTransport: false
+ property string adbDisplayInfoSerial: ""
+ property string adbDisplayInfoStdout: ""
+ property string adbDisplayInfoStderr: ""
+ property int adbScreenWidth: 0
+ property int adbScreenHeight: 0
+ property string adbScreenSerial: ""
+ property string adbScreenError: ""
+ property string adbScreenStateSerial: ""
+ property string adbScreenStateKnownSerial: ""
+ property string adbScreenStateRaw: ""
+ property string adbScreenStateError: ""
+ property string adbScreenLockState: "unknown"
+ property bool adbScreenInteractive: false
+ property bool adbUnlockNeeded: true
+ property string adbScreenTimeoutSerial: ""
+ property string adbScreenTimeoutKnownSerial: ""
+ property string adbScreenTimeoutRaw: ""
+ property string adbScreenTimeoutError: ""
+ property string adbScreenTimeoutValue: ""
+ property string adbScreenBrightnessSerial: ""
+ property string adbScreenBrightnessKnownSerial: ""
+ property string adbScreenBrightnessRaw: ""
+ property string adbScreenBrightnessError: ""
+ property string adbScreenBrightnessValue: ""
+ property string adbScreenBrightnessMode: ""
+ property string adbScreenshotSerial: ""
+ property string adbScreenshotPath: ""
+ property string adbScreenshotError: ""
+ readonly property bool adbScreenshotBusy: adbScreenshotProc.running
+ property string adbScreenRecordingSerial: ""
+ property string adbScreenRecordingRemotePath: ""
+ property string adbScreenRecordingLocalPath: ""
+ property string adbScreenRecordingError: ""
+ property bool adbScreenRecordingStopRequested: false
+ readonly property bool adbScreenRecordingActive: adbScreenRecordingProc.running
+ readonly property bool adbScreenRecordingBusy: adbScreenRecordingProc.running || adbScreenRecordingFinalizeProc.running || adbScreenRecordingStopProc.running
+ property string adbQueuedSerial: ""
+ property var adbQueuedArgs: []
+ property string adbQueuedKind: ""
+ property string adbQueuedStdout: ""
+ property string adbQueuedStderr: ""
+ property var adbCommandQueue: []
+ property bool reduceBackgroundRefresh: false
+ readonly property int refreshIntervalMs: reduceBackgroundRefresh ? 20000 : 5000
+ property double scrcpyLaunchStartedAtMs: 0
+
+ property bool anyDevicesConnected: false
+
+ signal wirelessAdbFinished(bool success, string message)
+ signal adbDevicesRefreshed()
+ signal adbScreenStateRefreshed(string serial, bool unlockNeeded, bool interactive, string lockState)
+ signal adbScreenTimeoutRead(string serial, string value, bool success)
+ signal adbScreenBrightnessRead(string serial, string mode, string value, bool success)
+
+ onDevicesChanged: {
+ setMainDevice(root.mainDeviceId)
+ }
+
+ Component.onCompleted: {
+ checkDaemon();
+ }
+
+ // Check if KDE Connect daemon is available
+ function checkDaemon(): void {
+ if (detectBusctlProc.running || daemonCheckProc.running || getDevicesProc.running || deviceRefreshInProgress)
+ return;
+
+ if (root.busctlCmd !== "") {
+ daemonCheckProc.running = true;
+ return;
+ }
+
+ detectBusctlProc.running = true;
+ }
+
+ // Refresh the list of devices
+ function refreshDevices(): void {
+ if (getDevicesProc.running || deviceRefreshInProgress)
+ return;
+
+ getDevicesProc.running = true;
+ }
+
+ function setMainDevice(deviceId: string): void {
+ root.mainDeviceId = deviceId;
+ updateMainDevice(false);
+ }
+
+ function updateMainDevice(checkReachable) {
+ let newMain;
+ if (checkReachable) {
+ newMain = devices.find((device) => device.id === root.mainDeviceId && device.reachable);
+ if (newMain === undefined)
+ newMain = devices.find((device) => device.reachable);
+ if (newMain === undefined)
+ newMain = devices.length === 0 ? null : devices[0];
+ } else {
+ newMain = devices.find((device) => device.id === root.mainDeviceId);
+ if (newMain === undefined)
+ newMain = devices.length === 0 ? null : devices[0];
+ }
+
+ if (root.mainDevice !== newMain) {
+ root.mainDevice = newMain;
+ }
+
+ anyDevicesConnected = devices.find((device) => device.reachable) !== undefined;
+ }
+
+ function notePairedObservation(deviceId: string, paired: bool): void {
+ const trimmedDeviceId = String(deviceId || "").trim();
+ if (trimmedDeviceId === "")
+ return;
+
+ if (!paired)
+ return;
+
+ const timestamps = Object.assign({}, pairedStateGraceTimestamps || {});
+ timestamps[trimmedDeviceId] = Date.now();
+ pairedStateGraceTimestamps = timestamps;
+ }
+
+ function shouldKeepPreviousPairedState(deviceId: string, currentPaired: bool, pairRequested: bool, verificationKey: string, previousPaired: bool): bool {
+ if (currentPaired || !previousPaired || pairRequested)
+ return false;
+
+ if (String(verificationKey || "").trim() !== "")
+ return false;
+
+ const trimmedDeviceId = String(deviceId || "").trim();
+ if (trimmedDeviceId === "")
+ return false;
+
+ const lastPairedAt = Number((pairedStateGraceTimestamps || {})[trimmedDeviceId] || 0);
+ if (!isFinite(lastPairedAt) || lastPairedAt <= 0)
+ return false;
+
+ return (Date.now() - lastPairedAt) <= pairedStateGraceMs;
+ }
+
+ function triggerFindMyPhone(deviceId: string): void {
+ startProcessComponent(findMyPhoneComponent, { deviceId: deviceId });
+ }
+
+ function browseFiles(deviceId: string): void {
+ startProcessComponent(browseFilesComponent, { deviceId: deviceId });
+ }
+
+ // Share a file with a device
+ function shareFile(deviceId: string, filePath: string): void {
+ startProcessComponent(shareComponent, {
+ deviceId: deviceId,
+ fileUrl: normalizedFileShareUrl(filePath)
+ });
+ }
+
+ function requestPairing(deviceId: string): void {
+ startProcessComponent(requestPairingComponent, { deviceId: deviceId });
+ }
+
+ function unpairDevice(deviceId: string): void {
+ startProcessComponent(unpairingComponent, { deviceId: deviceId });
+ }
+
+ function wakeUpDevice(deviceId: string): void {
+ startProcessComponent(wakeUpDeviceComponent, { deviceId: deviceId });
+ }
+
+ function formatActionFailure(action: string, stderrText: string, exitCode: int): string {
+ const actionLabel = String(action || "").trim() !== "" ? String(action).trim() : "Operation";
+ const details = String(stderrText || "").trim();
+
+ if (details !== "")
+ return actionLabel + " failed: " + details;
+
+ if (exitCode !== 0)
+ return actionLabel + " failed (exit code " + exitCode + ").";
+
+ return actionLabel + " failed.";
+ }
+
+ function escapeNotificationMarkdown(text: string): string {
+ return String(text || "")
+ .replace(/\\/g, "\\\\")
+ .replace(/\[/g, "\\[")
+ .replace(/\]/g, "\\]")
+ .replace(/\(/g, "\\(")
+ .replace(/\)/g, "\\)")
+ .replace(/\*/g, "\\*")
+ .replace(/_/g, "\\_")
+ .replace(/`/g, "\\`");
+ }
+
+ function notificationFileUrl(path: string): string {
+ const trimmedPath = String(path || "").trim();
+ if (trimmedPath === "")
+ return "";
+
+ const encodedSegments = trimmedPath.split("/").map(segment => encodeURIComponent(segment));
+ return "file://" + encodedSegments.join("/");
+ }
+
+ function notificationParentDirectory(path: string): string {
+ const trimmedPath = String(path || "").trim();
+ const lastSlash = trimmedPath.lastIndexOf("/");
+ if (lastSlash <= 0)
+ return "";
+ return trimmedPath.slice(0, lastSlash);
+ }
+
+ function notificationHistoryLink(label: string, path: string): string {
+ const fileUrl = notificationFileUrl(path);
+ if (fileUrl === "")
+ return "";
+ return "[" + escapeNotificationMarkdown(label) + "](" + fileUrl + ")";
+ }
+
+ function notificationHistoryId(prefix: string): string {
+ const trimmedPrefix = String(prefix || "androidconnect").trim();
+ const safePrefix = trimmedPrefix !== ""
+ ? trimmedPrefix.replace(/[^a-zA-Z0-9_-]+/g, "-")
+ : "androidconnect";
+ return safePrefix + "-" + String(Date.now()) + "-" + String(Math.random()).slice(2, 8);
+ }
+
+ function addHistoryNotification(summary: string, body: string, urgency = 1, options = {}): void {
+ const resolvedSummary = String(summary || "").trim();
+ const resolvedBody = String(body || "").trim();
+ const resolvedOptions = options && typeof options === "object" ? options : ({});
+ const fallbackSummary = resolvedSummary !== "" ? resolvedSummary : "AndroidConnect";
+ const fallbackBody = resolvedBody !== "" ? resolvedBody : fallbackSummary;
+
+ NotificationService.addToHistory({
+ id: notificationHistoryId(resolvedOptions.idPrefix || "androidconnect"),
+ summary: fallbackSummary,
+ summaryMarkdown: String(resolvedOptions.summaryMarkdown || "").trim() || escapeNotificationMarkdown(fallbackSummary),
+ body: fallbackBody,
+ bodyMarkdown: String(resolvedOptions.bodyMarkdown || "").trim() || escapeNotificationMarkdown(fallbackBody),
+ appName: String(resolvedOptions.appName || "AndroidConnect"),
+ urgency: urgency < 0 || urgency > 2 ? 1 : urgency,
+ expireTimeout: 0,
+ timestamp: new Date(),
+ originalImage: "",
+ cachedImage: "",
+ actionsJson: "[]",
+ originalId: 0
+ });
+ }
+
+ function showNoticeWithHistory(summary: string, body: string, icon = "", timeout = 3200, options = {}): void {
+ ToastService.showNotice(summary, body, icon, timeout);
+ addHistoryNotification(summary, body, 1, options);
+ }
+
+ function showWarningWithHistory(summary: string, body: string, timeout = 5000, options = {}): void {
+ ToastService.showWarning(summary, body, timeout);
+ addHistoryNotification(summary, body, 1, options);
+ }
+
+ function showErrorWithHistory(message: string, options = {}): void {
+ const resolvedOptions = options && typeof options === "object" ? options : ({});
+ const summary = String(resolvedOptions.summary || "AndroidConnect").trim() || "AndroidConnect";
+ const body = String(message || "").trim() || summary;
+ ToastService.showError(body);
+ addHistoryNotification(summary, body, 2, resolvedOptions);
+ }
+
+ function savedMediaNotificationOptions(title: string, outputPath: string, idPrefix: string): var {
+ const directoryPath = notificationParentDirectory(outputPath);
+ const directoryLink = notificationHistoryLink("Open folder", directoryPath);
+ const linkedTitle = notificationHistoryLink(title, directoryPath);
+ const escapedPath = escapeNotificationMarkdown(outputPath);
+
+ return {
+ idPrefix: idPrefix,
+ summaryMarkdown: linkedTitle !== "" ? linkedTitle : escapeNotificationMarkdown(title),
+ bodyMarkdown: directoryLink !== ""
+ ? escapedPath + "\n\n" + directoryLink
+ : escapedPath
+ };
+ }
+
+ function notifyActionFailure(action: string, stderrText: string, exitCode: int): void {
+ const message = formatActionFailure(action, stderrText, exitCode);
+ Logger.w("KDEConnect", message);
+ showErrorWithHistory(message);
+ }
+
+ function startProcessComponent(component, properties = {}): void {
+ const proc = component.createObject(root, properties);
+ proc.running = true;
+ }
+
+ function launchScrcpySession(deviceId: string, commandString: string): bool {
+ const trimmedCommand = (commandString || "").trim();
+ if (trimmedCommand === "") {
+ scrcpyLaunchError = "missing_command";
+ return false;
+ }
+
+ if (scrcpyRunning)
+ return false;
+
+ const parsedCommand = parseCommandArgs(trimmedCommand);
+ if (parsedCommand.error !== "") {
+ scrcpyLaunchError = parsedCommand.error;
+ return false;
+ }
+
+ const sinkMatch = trimmedCommand.match(/--v4l2-sink=(?:'([^']*)'|\"([^\"]*)\"|(\S+))/);
+ const feedDevicePath = sinkMatch
+ ? String(sinkMatch[1] || sinkMatch[2] || sinkMatch[3] || "").trim()
+ : "";
+ if (feedDevicePath === "") {
+ scrcpyLaunchError = "Embedded mirror requires a V4L2 sink device.";
+ return false;
+ }
+
+ scrcpyLaunching = true;
+ scrcpyStopRequested = false;
+ scrcpyLaunchError = "";
+ scrcpyLastStderr = "";
+ scrcpyDeviceId = deviceId;
+ scrcpyFeedDevicePath = feedDevicePath;
+ const serialMatch = trimmedCommand.match(/(?:^|\s)(?:-s|--serial)(?:=|\s+)(?:'([^']*)'|\"([^\"]*)\"|(\S+))/);
+ let launchSerial = serialMatch
+ ? String(serialMatch[1] || serialMatch[2] || serialMatch[3] || "")
+ : "";
+ if (launchSerial === "" && /(^|\s)(?:-d|--select-usb)\b/.test(trimmedCommand))
+ launchSerial = root.usbSelectionSentinel;
+ scrcpyActiveSerial = launchSerial;
+ scrcpyLaunchStartedAtMs = Date.now();
+ scrcpyCommandArgs = [];
+ scrcpyPendingCommandArgs = parsedCommand.args;
+ Logger.i("KDEConnect", "Preparing scrcpy session:",
+ "deviceId=" + scrcpyDeviceId,
+ "serial=" + (isUsbSelectionSerial(launchSerial) ? "usb" : launchSerial),
+ "program=" + String(parsedCommand.args[0] || ""));
+ scrcpyPreLaunchProc.running = true;
+ return true;
+ }
+
+ function stopScrcpySession(): void {
+ if (scrcpyPreLaunchProc.running) {
+ scrcpyStopRequested = true;
+ scrcpyPreLaunchProc.signal(15);
+ return;
+ }
+
+ if (!scrcpyRunning)
+ return;
+
+ scrcpyStopRequested = true;
+ scrcpySessionProc.signal(15);
+ }
+
+ function forceStopScrcpyProcesses(feedDevicePath: string): void {
+ scrcpyCleanupFeedDevicePath = String(feedDevicePath || "").trim();
+
+ if (scrcpyRunning || scrcpyPreLaunchProc.running)
+ stopScrcpySession();
+
+ if (!scrcpyCleanupProc.running)
+ scrcpyCleanupProc.running = true;
+ }
+
+ function shellQuote(value: string): string {
+ return "'" + String(value).replace(/'/g, "'\"'\"'") + "'";
+ }
+
+ function normalizeShellCommand(commandString: string): string {
+ return String(commandString || "").replace(/\s+/g, " ").trim();
+ }
+
+ function parseCommandArgs(commandString: string): var {
+ const source = String(commandString || "").trim();
+ const parsedArgs = [];
+ let current = "";
+ let quoteChar = "";
+ let escaping = false;
+ let tokenStarted = false;
+
+ if (source === "")
+ return { error: "missing_command", args: [] };
+
+ for (let i = 0; i < source.length; ++i) {
+ const ch = source.charAt(i);
+
+ if (escaping) {
+ current += ch;
+ escaping = false;
+ tokenStarted = true;
+ continue;
+ }
+
+ if (quoteChar === "'") {
+ if (ch === "'")
+ quoteChar = "";
+ else
+ current += ch;
+ tokenStarted = true;
+ continue;
+ }
+
+ if (quoteChar === "\"") {
+ if (ch === "\"") {
+ quoteChar = "";
+ } else if (ch === "\\") {
+ escaping = true;
+ } else {
+ current += ch;
+ }
+ tokenStarted = true;
+ continue;
+ }
+
+ if (ch === "\\") {
+ escaping = true;
+ tokenStarted = true;
+ continue;
+ }
+
+ if (ch === "'" || ch === "\"") {
+ quoteChar = ch;
+ tokenStarted = true;
+ continue;
+ }
+
+ if (/\s/.test(ch)) {
+ if (tokenStarted) {
+ parsedArgs.push(current);
+ current = "";
+ tokenStarted = false;
+ }
+ continue;
+ }
+
+ current += ch;
+ tokenStarted = true;
+ }
+
+ if (escaping)
+ return { error: "Command ends with an unfinished escape.", args: [] };
+
+ if (quoteChar !== "")
+ return { error: "Command has an unterminated quote.", args: [] };
+
+ if (tokenStarted)
+ parsedArgs.push(current);
+
+ if (parsedArgs.length === 0 || String(parsedArgs[0] || "").trim() === "")
+ return { error: "missing_command", args: [] };
+
+ return { error: "", args: parsedArgs };
+ }
+
+ function isUsbSelectionSerial(serial: string): bool {
+ return String(serial || "").trim() === root.usbSelectionSentinel;
+ }
+
+ function scrcpyCommandHasOption(commandString: string, optionPattern): bool {
+ return optionPattern.test(normalizeShellCommand(commandString));
+ }
+
+ function appendScrcpyOption(commandString: string, optionPattern, optionText: string): string {
+ if (scrcpyCommandHasOption(commandString, optionPattern))
+ return normalizeShellCommand(commandString);
+
+ return normalizeShellCommand(commandString + " " + optionText);
+ }
+
+ function applyConfiguredMirrorAudioMode(commandString: string, audioEnabled: bool): string {
+ let command = normalizeShellCommand(commandString);
+ if (command === "")
+ return "";
+
+ command = command.replace(/(^|\s)--no-audio\b/g, " ");
+ command = normalizeShellCommand(command);
+
+ if (!audioEnabled)
+ command = appendScrcpyOption(command, /(^|\s)--no-audio\b/, "--no-audio");
+
+ return command;
+ }
+
+ function buildScrcpyFeedCommand(commandString: string, videoDevice: string, deviceSerial: string): string {
+ let command = normalizeShellCommand(commandString);
+ const trimmedDevice = String(videoDevice || "").trim();
+ const trimmedSerial = String(deviceSerial || "").trim();
+ if (command === "" || trimmedDevice === "")
+ return "";
+
+ command = command
+ .replace(/(^|\s)--no-window\b/g, " ")
+ .replace(/(^|\s)--no-video-playback\b/g, " ")
+ .replace(/(^|\s)--no-control\b/g, " ")
+ .replace(/(^|\s)--v4l2-sink(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)-s(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)--serial(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)-d\b/g, " ")
+ .replace(/(^|\s)-e\b/g, " ")
+ .replace(/(^|\s)--select-usb\b/g, " ")
+ .replace(/(^|\s)--select-tcpip\b/g, " ")
+ .replace(/(^|\s)--tcpip(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)--window-title(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)--window-x(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)--window-y(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)--window-width(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)--window-height(?:=\S+|\s+\S+)/g, " ")
+ .replace(/(^|\s)--window-borderless\b/g, " ")
+ .replace(/(^|\s)--always-on-top\b/g, " ");
+
+ command = normalizeShellCommand(command);
+ command += " --no-window";
+ command += " --no-video-playback";
+ command += " --no-control";
+ command += " --v4l2-sink=" + shellQuote(trimmedDevice);
+ if (isUsbSelectionSerial(trimmedSerial))
+ command += " --select-usb";
+ else if (trimmedSerial !== "")
+ command += " --serial=" + shellQuote(trimmedSerial);
+ return command;
+ }
+
+ function runWirelessAdbCommandArgs(commandArgs): bool {
+ if (!Array.isArray(commandArgs) || commandArgs.length === 0) {
+ wirelessAdbFinished(false, "missing_command");
+ return false;
+ }
+
+ if (wirelessAdbBusy)
+ return false;
+
+ wirelessAdbBusy = true;
+ wirelessAdbLastStdout = "";
+ wirelessAdbLastStderr = "";
+ wirelessAdbCommandArgs = commandArgs;
+ wirelessAdbProc.running = true;
+ return true;
+ }
+
+ function enableWirelessAdb(commandString: string): bool {
+ const parsedCommand = parseCommandArgs(commandString);
+ if (parsedCommand.error !== "") {
+ wirelessAdbFinished(false, parsedCommand.error);
+ return false;
+ }
+
+ return runWirelessAdbCommandArgs(parsedCommand.args);
+ }
+
+ function refreshAdbDevices(): bool {
+ if (adbDevicesProc.running)
+ return false;
+
+ adbDevicesStdout = "";
+ adbDevicesStderr = "";
+ adbDevicesExitCode = 0;
+ adbDevicesProc.running = true;
+ return true;
+ }
+
+ function adbDeviceSerialConnected(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ if (trimmedSerial === "")
+ return false;
+
+ return (adbConnectedSerials || []).indexOf(trimmedSerial) !== -1;
+ }
+
+ function adbConnectedSerialForHost(host: string): string {
+ const trimmedHost = String(host || "").trim();
+ if (trimmedHost === "")
+ return "";
+
+ const hostPrefix = trimmedHost + ":";
+ const connectedSerials = adbConnectedSerials || [];
+ for (let i = 0; i < connectedSerials.length; ++i) {
+ const serial = String(connectedSerials[i] || "").trim();
+ if (serial.indexOf(hostPrefix) === 0)
+ return serial;
+ }
+
+ return "";
+ }
+
+ function pairWirelessAdb(host: string, port: string, pairingCode: string): bool {
+ const trimmedHost = (host || "").trim();
+ const trimmedPort = (port || "").trim();
+ const trimmedCode = (pairingCode || "").trim();
+
+ if (trimmedHost === "" || trimmedPort === "" || trimmedCode === "") {
+ wirelessAdbFinished(false, "missing_pair_parameters");
+ return false;
+ }
+
+ return runWirelessAdbCommandArgs([
+ "adb",
+ "pair",
+ trimmedHost + ":" + trimmedPort,
+ trimmedCode
+ ]);
+ }
+
+ function pairWirelessAdbByQr(instanceName: string, pairingSecret: string, timeoutSeconds: int): bool {
+ const trimmedInstance = (instanceName || "").trim();
+ const trimmedSecret = (pairingSecret || "").trim();
+ const timeout = Math.max(20, Math.min(180, Math.round(timeoutSeconds || 90)));
+
+ if (trimmedInstance === "" || trimmedSecret === "") {
+ wirelessAdbFinished(false, "missing_qr_parameters");
+ return false;
+ }
+
+ const script = [
+ "inst=\"$1\"",
+ "secret=\"$2\"",
+ "timeout_secs=\"$3\"",
+ "discover_service_endpoint() {",
+ "service_type=\"$1\"",
+ "wanted_name=\"$2\"",
+ "wanted_host=\"$3\"",
+ "endpoint=\"\"",
+ "if command -v avahi-browse >/dev/null 2>&1; then",
+ "avahi_output=$(avahi-browse -rtkp \"$service_type\" 2>/dev/null || true)",
+ "endpoint=$(printf '%s\\n' \"$avahi_output\" | awk -F';' -v type=\"$service_type\" -v name=\"$wanted_name\" -v host=\"$wanted_host\" '($1 == \"=\" || $1 == \"+\") && $5 == type { svc_name=$4; svc_host=$8; svc_port=$9; if (name != \"\" && svc_name != name) next; if (host != \"\" && index(svc_host, host) != 1) next; if (svc_host != \"\" && svc_port != \"\") { print svc_host \":\" svc_port; exit } }')",
+ "fi",
+ "if [ -n \"$endpoint\" ]; then",
+ "printf '%s\\n' \"$endpoint\"",
+ "return 0",
+ "fi",
+ "mdns_output=$(ADB_MDNS_OPENSCREEN=1 adb mdns services 2>&1 || true)",
+ "if printf '%s\\n' \"$mdns_output\" | grep -q \"unknown host service 'mdns:\"; then",
+ "return 1",
+ "fi",
+ "endpoint=$(printf '%s\\n' \"$mdns_output\" | awk -v type=\"$service_type\" -v name=\"$wanted_name\" -v host=\"$wanted_host\" '{ if (index($0, type) == 0) next; n=split($0, a, /[[:space:]]+/); svc_name=\"\"; svc_endpoint=\"\"; for (i=1; i<=n; ++i) { if (a[i] == type && i > 1) svc_name=a[i-1]; if (a[i] ~ /^[0-9.]+:[0-9]+$/) svc_endpoint=a[i]; } if (name != \"\" && svc_name != name) next; if (host != \"\" && index(svc_endpoint, host \":\") != 1) next; if (svc_endpoint != \"\") { print svc_endpoint; exit } }')",
+ "[ -n \"$endpoint\" ] && printf '%s\\n' \"$endpoint\"",
+ "}",
+ "ADB_MDNS_OPENSCREEN=1 adb start-server >/dev/null 2>&1 || true",
+ "deadline=$(( $(date +%s) + timeout_secs ))",
+ "while [ \"$(date +%s)\" -lt \"$deadline\" ]; do",
+ "pair_endpoint=$(discover_service_endpoint \"_adb-tls-pairing._tcp\" \"$inst\" \"\")",
+ "if [ -n \"$pair_endpoint\" ]; then",
+ "pair_host=${pair_endpoint%:*}",
+ "pair_port=${pair_endpoint##*:}",
+ "pair_output=$(adb pair \"$pair_host:$pair_port\" \"$secret\" 2>&1)",
+ "pair_status=$?",
+ "if [ \"$pair_status\" -ne 0 ]; then",
+ "printf '%s\\n' \"$pair_output\" >&2",
+ "exit \"$pair_status\"",
+ "fi",
+ "connect_deadline=$(( $(date +%s) + 25 ))",
+ "while [ \"$(date +%s)\" -lt \"$connect_deadline\" ]; do",
+ "connect_endpoint=$(discover_service_endpoint \"_adb-tls-connect._tcp\" \"\" \"$pair_host\")",
+ "if [ -n \"$connect_endpoint\" ]; then",
+ "connect_host=${connect_endpoint%:*}",
+ "connect_port=${connect_endpoint##*:}",
+ "connect_output=$(adb connect \"$connect_host:$connect_port\" 2>&1)",
+ "connect_status=$?",
+ "if [ \"$connect_status\" -ne 0 ]; then",
+ "printf '%s\\n' \"$connect_output\" >&2",
+ "exit \"$connect_status\"",
+ "fi",
+ "printf 'QR_OK host=%s pair_port=%s connect_port=%s\\n' \"$pair_host\" \"$pair_port\" \"$connect_port\"",
+ "exit 0",
+ "fi",
+ "sleep 1",
+ "done",
+ "echo 'Timed out waiting for the Wireless ADB connect service after the QR scan.' >&2",
+ "exit 124",
+ "fi",
+ "sleep 1",
+ "done",
+ "echo 'Timed out waiting for the phone to scan the Wireless ADB QR code.' >&2",
+ "exit 124"
+ ].join("\n");
+
+ return runWirelessAdbCommandArgs([
+ "bash",
+ "-c",
+ script,
+ "--",
+ trimmedInstance,
+ trimmedSecret,
+ String(timeout)
+ ]);
+ }
+
+ function connectWirelessAdb(host: string, port: string): bool {
+ const trimmedHost = (host || "").trim();
+ const trimmedPort = (port || "").trim();
+
+ if (trimmedHost === "" || trimmedPort === "") {
+ wirelessAdbFinished(false, "missing_connect_parameters");
+ return false;
+ }
+
+ return runWirelessAdbCommandArgs([
+ "adb",
+ "connect",
+ trimmedHost + ":" + trimmedPort
+ ]);
+ }
+
+ function adbCommand(serial: string, args): var {
+ return ["adb"]
+ .concat(adbSelectorArgsForSerial(serial))
+ .concat(args.map(arg => String(arg)));
+ }
+
+ function buildOutputPath(directoryName: string, filePrefix: string, extension: string): string {
+ const homeDir = String(Quickshell.env("HOME") || "").trim();
+ const baseDir = homeDir !== ""
+ ? (homeDir + "/" + String(directoryName || "").trim() + "/AndroidConnect")
+ : "/tmp/AndroidConnect";
+ const stamp = Qt.formatDateTime(new Date(), "yyyyMMdd-HHmmss");
+ return baseDir + "/" + String(filePrefix || "capture").trim() + "-" + stamp + String(extension || "");
+ }
+
+ function takeAdbScreenshot(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ if (trimmedSerial === "" || adbScreenshotProc.running)
+ return false;
+
+ adbScreenshotSerial = trimmedSerial;
+ adbScreenshotPath = buildOutputPath("Pictures", "androidconnect-screenshot", ".png");
+ adbScreenshotError = "";
+ adbScreenshotProc.running = true;
+ return true;
+ }
+
+ function startAdbScreenRecording(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ if (trimmedSerial === "" || adbScreenRecordingBusy)
+ return false;
+
+ const stamp = Qt.formatDateTime(new Date(), "yyyyMMdd-HHmmss");
+ adbScreenRecordingSerial = trimmedSerial;
+ adbScreenRecordingRemotePath = "/sdcard/Download/androidconnect-recording-" + stamp + ".mp4";
+ adbScreenRecordingLocalPath = buildOutputPath("Videos", "androidconnect-recording", ".mp4");
+ adbScreenRecordingError = "";
+ adbScreenRecordingStopRequested = false;
+ adbScreenRecordingProc.running = true;
+ showNoticeWithHistory("Screen Recording", "Recording started.", "video");
+ Logger.i("KDEConnect", "ADB screen recording started:",
+ "serial=" + adbScreenRecordingSerial,
+ "remote=" + adbScreenRecordingRemotePath,
+ "local=" + adbScreenRecordingLocalPath);
+ return true;
+ }
+
+ function stopAdbScreenRecording(): bool {
+ if (!adbScreenRecordingProc.running || adbScreenRecordingStopProc.running)
+ return false;
+
+ adbScreenRecordingStopRequested = true;
+ adbScreenRecordingStopProc.running = true;
+ return true;
+ }
+
+ function adbSelectorArgsForSerial(serial: string): var {
+ const trimmedSerial = String(serial || "").trim();
+ if (isUsbSelectionSerial(trimmedSerial))
+ return ["-d"];
+ if (trimmedSerial !== "")
+ return ["-s", trimmedSerial];
+ return [];
+ }
+
+ function buildScrcpyPreLaunchCommand(serial: string, feedDevicePath: string): var {
+ const adbPrefix = shellJoinArgs(["adb"].concat(adbSelectorArgsForSerial(serial)));
+ const device = String(feedDevicePath || "").trim();
+ const preLaunchStateScript = [
+ "power=$(dumpsys power 2>/dev/null || true)",
+ "policy=$(dumpsys window policy 2>/dev/null || true)",
+ "interactive=false",
+ "if printf '%s\\n' \"$power\" | grep -Eq 'mWakefulness=Awake|mInteractive=true|Display Power: state=ON'; then interactive=true; fi",
+ "locked=unknown",
+ "if printf '%s\\n' \"$policy\" | grep -Eq 'showing=true|mShowingLockscreen=true|isStatusBarKeyguard=true'; then locked=true;"
+ + " elif printf '%s\\n' \"$policy\" | grep -Eq 'showing=false|mShowingLockscreen=false|isStatusBarKeyguard=false'; then locked=false; fi",
+ "if [ \"$interactive\" != true ]; then input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true; sleep 0.05; fi",
+ "if [ \"$locked\" = true ]; then input keyevent 82 >/dev/null 2>&1 || true; sleep 0.10; fi",
+ ].join("; ");
+ const script = [
+ "device=" + shellQuote(device),
+ "for pid in $(pgrep -x scrcpy || true); do",
+ " cmd=$(tr '\\0' '\\n' /dev/null || true)",
+ " if [ -n \"$device\" ] && printf '%s\\n' \"$cmd\" | grep -Fqx -- \"--v4l2-sink=$device\"; then",
+ " kill -TERM \"$pid\" 2>/dev/null || true",
+ " fi",
+ "done",
+ adbPrefix + " shell sh -c " + shellQuote(preLaunchStateScript) + " >/dev/null 2>&1 || true"
+ ].join("\n");
+
+ return ["sh", "-lc", script];
+ }
+
+ function shellJoinArgs(args): string {
+ return (Array.isArray(args) ? args : []).map(arg => shellQuote(String(arg))).join(" ");
+ }
+
+ function hasQueuedAdbTask(kind: string, serial: string): bool {
+ const trimmedKind = String(kind || "").trim();
+ const trimmedSerial = String(serial || "").trim();
+
+ if (trimmedKind === "")
+ return false;
+
+ if (adbQueuedKind === trimmedKind && adbQueuedSerial === trimmedSerial)
+ return true;
+
+ const queuedTasks = adbCommandQueue || [];
+ for (let i = 0; i < queuedTasks.length; ++i) {
+ const task = queuedTasks[i];
+ if (String(task.kind || "").trim() === trimmedKind
+ && String(task.serial || "").trim() === trimmedSerial)
+ return true;
+ }
+
+ return false;
+ }
+
+ function queueAdbTask(kind: string, serial: string, args): bool {
+ const normalizedArgs = args.map(arg => String(arg));
+ const queuedTasks = (adbCommandQueue || []).slice(0);
+
+ queuedTasks.push({
+ kind: String(kind || "").trim(),
+ serial: String(serial || "").trim(),
+ args: normalizedArgs
+ });
+ adbCommandQueue = queuedTasks;
+ runNextAdbTask();
+ return true;
+ }
+
+ function runNextAdbTask(): void {
+ if (adbQueuedProc.running)
+ return;
+
+ const queuedTasks = adbCommandQueue || [];
+ if (queuedTasks.length === 0)
+ return;
+
+ const nextTask = queuedTasks[0];
+ adbQueuedKind = String(nextTask.kind || "").trim();
+ adbQueuedSerial = String(nextTask.serial || "").trim();
+ adbQueuedArgs = Array.isArray(nextTask.args) ? nextTask.args : [];
+ adbQueuedStdout = "";
+ adbQueuedStderr = "";
+ adbQueuedProc.running = true;
+ }
+
+ function finishCurrentAdbTask(): void {
+ const queuedTasks = (adbCommandQueue || []).slice(0);
+ if (queuedTasks.length > 0)
+ queuedTasks.shift();
+
+ adbCommandQueue = queuedTasks;
+ adbQueuedKind = "";
+ adbQueuedSerial = "";
+ adbQueuedArgs = [];
+ adbQueuedStdout = "";
+ adbQueuedStderr = "";
+
+ Qt.callLater(function() {
+ root.runNextAdbTask();
+ });
+ }
+
+ function queryAdbDisplayInfo(serial: string): bool {
+ const trimmedSerial = (serial || "").trim();
+ if (trimmedSerial === ""
+ || adbDisplayInfoSerial !== ""
+ || hasQueuedAdbTask("display-info", trimmedSerial))
+ return false;
+
+ adbDisplayInfoSerial = trimmedSerial;
+ adbDisplayInfoStdout = "";
+ adbDisplayInfoStderr = "";
+ adbScreenError = "";
+ return queueAdbTask("display-info", trimmedSerial, ["shell", "wm", "size"]);
+ }
+
+ function hasFreshAdbScreenState(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ return trimmedSerial !== ""
+ && adbScreenStateKnownSerial === trimmedSerial
+ && adbScreenStateError === "";
+ }
+
+ function hasFreshAdbScreenTimeout(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ return trimmedSerial !== ""
+ && adbScreenTimeoutKnownSerial === trimmedSerial
+ && adbScreenTimeoutError === "";
+ }
+
+ function hasFreshAdbScreenBrightness(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ return trimmedSerial !== ""
+ && adbScreenBrightnessKnownSerial === trimmedSerial
+ && adbScreenBrightnessError === "";
+ }
+
+ function queryAdbScreenState(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ if (trimmedSerial === ""
+ || adbScreenStateSerial !== ""
+ || hasQueuedAdbTask("screen-state", trimmedSerial))
+ return false;
+
+ adbScreenStateSerial = trimmedSerial;
+ adbScreenStateKnownSerial = "";
+ adbScreenStateRaw = "";
+ adbScreenStateError = "";
+ return queueAdbTask("screen-state", trimmedSerial, [
+ "shell",
+ "sh",
+ "-c",
+ "power=$(dumpsys power 2>/dev/null || true)"
+ + "; policy=$(dumpsys window policy 2>/dev/null || true)"
+ + "; interactive=false"
+ + "; if printf '%s\\n' \"$power\" | grep -Eq 'mWakefulness=Awake|mInteractive=true|Display Power: state=ON'; then interactive=true; fi"
+ + "; locked=unknown"
+ + "; if printf '%s\\n' \"$policy\" | grep -Eq 'showing=true|mShowingLockscreen=true|isStatusBarKeyguard=true'; then locked=true;"
+ + " elif printf '%s\\n' \"$policy\" | grep -Eq 'showing=false|mShowingLockscreen=false|isStatusBarKeyguard=false'; then locked=false; fi"
+ + "; unlockNeeded=false"
+ + "; if [ \"$locked\" = true ]; then unlockNeeded=true; fi"
+ + "; printf 'interactive=%s\\nlocked=%s\\nunlockNeeded=%s\\n' \"$interactive\" \"$locked\" \"$unlockNeeded\""
+ ]);
+ }
+
+ function queryAdbScreenTimeout(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ if (trimmedSerial === ""
+ || adbScreenTimeoutSerial !== ""
+ || hasQueuedAdbTask("screen-timeout", trimmedSerial))
+ return false;
+
+ adbScreenTimeoutSerial = trimmedSerial;
+ adbScreenTimeoutKnownSerial = "";
+ adbScreenTimeoutRaw = "";
+ adbScreenTimeoutError = "";
+ adbScreenTimeoutValue = "";
+ return queueAdbTask("screen-timeout", trimmedSerial, [
+ "shell",
+ "sh",
+ "-c",
+ "settings get system screen_off_timeout 2>/dev/null || true"
+ ]);
+ }
+
+ function queryAdbScreenBrightness(serial: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ if (trimmedSerial === ""
+ || adbScreenBrightnessSerial !== ""
+ || hasQueuedAdbTask("screen-brightness", trimmedSerial))
+ return false;
+
+ adbScreenBrightnessSerial = trimmedSerial;
+ adbScreenBrightnessKnownSerial = "";
+ adbScreenBrightnessRaw = "";
+ adbScreenBrightnessError = "";
+ adbScreenBrightnessValue = "";
+ adbScreenBrightnessMode = "";
+ return queueAdbTask("screen-brightness", trimmedSerial, [
+ "shell",
+ "sh",
+ "-c",
+ "brightness=$(settings get system screen_brightness 2>/dev/null || true)"
+ + "; mode=$(settings get system screen_brightness_mode 2>/dev/null || true)"
+ + "; printf 'brightness=%s\\nmode=%s\\n' \"$brightness\" \"$mode\""
+ ]);
+ }
+
+ function setAdbScreenTimeout(serial: string, timeoutValue: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ const trimmedValue = String(timeoutValue || "").trim();
+ if (trimmedSerial === "" || trimmedValue === "")
+ return false;
+
+ return queueAdbTask("screen-timeout-set", trimmedSerial, [
+ "shell",
+ "settings",
+ "put",
+ "system",
+ "screen_off_timeout",
+ trimmedValue
+ ]);
+ }
+
+ function setAdbScreenBrightness(serial: string, brightnessValue: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ const trimmedBrightness = String(brightnessValue || "").trim();
+ if (trimmedSerial === "" || trimmedBrightness === "")
+ return false;
+
+ let normalizedBrightness = Number(trimmedBrightness);
+ if (!isFinite(normalizedBrightness))
+ return false;
+
+ normalizedBrightness = Math.max(0, Math.min(255, normalizedBrightness));
+
+ return queueAdbTask("screen-brightness-set", trimmedSerial, [
+ "shell",
+ "cmd",
+ "display",
+ "set-brightness",
+ (normalizedBrightness / 255).toFixed(4)
+ ]);
+ }
+
+ function restoreAdbScreenTimeout(serial: string, timeoutValue: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ const trimmedValue = String(timeoutValue || "").trim();
+ if (trimmedSerial === "")
+ return false;
+
+ if (/^\d+$/.test(trimmedValue))
+ return setAdbScreenTimeout(trimmedSerial, trimmedValue);
+
+ return queueAdbTask("screen-timeout-restore", trimmedSerial, [
+ "shell",
+ "settings",
+ "delete",
+ "system",
+ "screen_off_timeout"
+ ]);
+ }
+
+ function restoreAdbScreenBrightness(serial: string, modeValue: string, brightnessValue: string): bool {
+ const trimmedSerial = String(serial || "").trim();
+ const trimmedMode = String(modeValue || "").trim();
+ const trimmedBrightness = String(brightnessValue || "").trim();
+ if (trimmedSerial === "")
+ return false;
+
+ if (trimmedMode === "1") {
+ return queueAdbTask("screen-brightness-restore", trimmedSerial, [
+ "shell",
+ "cmd",
+ "display",
+ "reset-brightness-configuration"
+ ]);
+ }
+
+ if (/^\d+$/.test(trimmedBrightness)) {
+ let normalizedBrightness = Number(trimmedBrightness);
+ normalizedBrightness = Math.max(0, Math.min(255, normalizedBrightness));
+ return queueAdbTask("screen-brightness-restore", trimmedSerial, [
+ "shell",
+ "cmd",
+ "display",
+ "set-brightness",
+ (normalizedBrightness / 255).toFixed(4)
+ ]);
+ }
+
+ return queueAdbTask("screen-brightness-restore", trimmedSerial, [
+ "shell",
+ "cmd",
+ "display",
+ "reset-brightness-configuration"
+ ]);
+ }
+
+ function runAdbTap(serial: string, x: int, y: int): bool {
+ return queueAdbTask("tap", serial, [
+ "shell",
+ "input",
+ "tap",
+ Math.max(0, Math.round(x)),
+ Math.max(0, Math.round(y))
+ ]);
+ }
+
+ function runAdbSwipe(serial: string, x1: int, y1: int, x2: int, y2: int, durationMs: int): bool {
+ return queueAdbTask("swipe", serial, [
+ "shell",
+ "input",
+ "swipe",
+ Math.max(0, Math.round(x1)),
+ Math.max(0, Math.round(y1)),
+ Math.max(0, Math.round(x2)),
+ Math.max(0, Math.round(y2)),
+ Math.max(50, Math.round(durationMs))
+ ]);
+ }
+
+ function runAdbKeyevent(serial: string, keyCode: int): bool {
+ return queueAdbTask("keyevent", serial, [
+ "shell",
+ "input",
+ "keyevent",
+ Math.max(0, Math.round(keyCode))
+ ]);
+ }
+
+ function encodeAdbInputText(text: string): string {
+ const rawText = String(text || "");
+ let encoded = "";
+
+ for (let i = 0; i < rawText.length; ++i) {
+ const ch = rawText.charAt(i);
+
+ if (ch === " ") {
+ encoded += "%s";
+ continue;
+ }
+
+ if (/^[A-Za-z0-9._,:@\/+=-]$/.test(ch)) {
+ encoded += ch;
+ continue;
+ }
+
+ encoded += "\\" + ch;
+ }
+
+ return encoded;
+ }
+
+ function runAdbText(serial: string, text: string): bool {
+ const encodedText = encodeAdbInputText(text);
+ if (encodedText === "")
+ return false;
+
+ return queueAdbTask("text", serial, [
+ "shell",
+ "input",
+ "text",
+ encodedText
+ ]);
+ }
+
+ function busctlCall(obj, itf, method, params = []) {
+ let result = [ root.busctlCmd, "--user", "call", "--json=short", "org.kde.kdeconnect", obj, itf, method ];
+ return result.concat(params);
+ }
+
+ function busctlGet(obj, itf, prop) {
+ return [ root.busctlCmd, "--user", "get-property", "--json=short", "org.kde.kdeconnect", obj, itf, prop ];
+ }
+
+ function busctlData(text) {
+ if (text === "")
+ return "";
+
+ try {
+ let result = JSON.parse(text)?.data;
+ if (Array.isArray(result) && Array.isArray(result[0]))
+ return result[0]
+ else
+ return result;
+ } catch (e) {
+ Logger.e("KDEConnect", "Failed to parse busctl response: ", text)
+ return null;
+ }
+ }
+
+ function normalizedFileShareUrl(filePath: string): string {
+ const rawPath = String(filePath || "").trim();
+ if (rawPath === "")
+ return "";
+
+ if (rawPath.startsWith("file://"))
+ return rawPath;
+
+ return "file://" + encodeURI(rawPath);
+ }
+
+ function deviceNameForId(deviceId: string): string {
+ const trimmedDeviceId = String(deviceId || "").trim();
+ if (trimmedDeviceId === "")
+ return "device";
+
+ const matchedDevice = (devices || []).find(device => String(device?.id || "").trim() === trimmedDeviceId);
+ const name = String(matchedDevice?.name || "").trim();
+ return name !== "" ? name : trimmedDeviceId;
+ }
+
+ function formatProcessFailure(action: string, deviceId: string, stderrText: string, exitCode: int): string {
+ const actionLabel = String(action || "").trim() !== "" ? String(action).trim() : "Operation";
+ const targetLabel = deviceNameForId(deviceId);
+ const details = String(stderrText || "").trim();
+
+ if (details !== "")
+ return actionLabel + " failed for " + targetLabel + ": " + details;
+
+ if (exitCode !== 0)
+ return actionLabel + " failed for " + targetLabel + " (exit code " + exitCode + ").";
+
+ return actionLabel + " failed for " + targetLabel + ".";
+ }
+
+ function notifyProcessFailure(action: string, deviceId: string, stderrText: string, exitCode: int): void {
+ const message = formatProcessFailure(action, deviceId, stderrText, exitCode);
+ Logger.w("KDEConnect", message);
+ showErrorWithHistory(message);
+ }
+
+ property Process detectBusctlProc: Process {
+ command: ["which", "busctl"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (root.busctlCmd !== "") {
+ root.daemonCheckProc.running = true
+ return
+ }
+
+ let location = text.trim()
+ if (location !== "") {
+ root.busctlCmd = location
+ root.daemonCheckProc.running = true
+ Logger.i("KDEConnect", "Found busctl command:", location)
+ }
+ }
+ }
+ }
+
+ // Check daemon
+ property Process daemonCheckProc: Process {
+ command: [root.busctlCmd, "--user", "status", "org.kde.kdeconnect"]
+ onExited: (exitCode, exitStatus) => {
+ root.daemonAvailable = exitCode == 0;
+ if (root.daemonAvailable) {
+ if (root.reduceBackgroundRefresh)
+ return;
+ forceOnNetworkChange.running = true;
+ } else {
+ root.devices = []
+ root.mainDevice = null
+ }
+ }
+ }
+
+ property Process forceOnNetworkChange: Process {
+ command: busctlCall("/modules/kdeconnect", "org.kde.kdeconnect.daemon", "forceOnNetworkChange")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ getDevicesProc.running = true;
+ }
+ }
+}
+
+ // Get device list
+ property Process getDevicesProc: Process {
+ command: busctlCall("/modules/kdeconnect", "org.kde.kdeconnect.daemon", "devices")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const deviceIds = busctlData(text);
+ const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds : [];
+
+ root.pendingDevices = [];
+ root.pendingDeviceCount = normalizedDeviceIds.length;
+ root.deviceRefreshGeneration += 1;
+ const refreshGeneration = root.deviceRefreshGeneration;
+
+ if (normalizedDeviceIds.length === 0) {
+ root.deviceRefreshInProgress = false;
+ root.devices = [];
+ root.updateMainDevice(true);
+ return;
+ }
+
+ root.deviceRefreshInProgress = true;
+
+ normalizedDeviceIds.forEach(deviceId => {
+ const loader = deviceLoaderComponent.createObject(root, {
+ deviceId: deviceId,
+ refreshGeneration: refreshGeneration
+ });
+ loader.start();
+ });
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0)
+ root.deviceRefreshInProgress = false;
+ }
+ }
+
+ // Component that loads all info for a single device
+ property Component deviceLoaderComponent: Component {
+ QtObject {
+ id: loader
+ property string deviceId: ""
+ property int refreshGeneration: 0
+ property var deviceData: ({
+ id: deviceId,
+ name: "",
+ reachable: false,
+ paired: false,
+ pairRequested: false,
+ verificationKey: "",
+ charging: false,
+ battery: -1,
+ cellularNetworkType: "",
+ cellularNetworkStrength: -1,
+ notificationIds: []
+ })
+ property bool notificationsQueryFailed: false
+
+ function start() {
+ nameProc.running = true
+ }
+
+ property Process nameProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId, "org.kde.kdeconnect.device", "name")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ loader.deviceData.name = busctlData(text);
+
+ reachableProc.running = true;
+ }
+ }
+ }
+
+ property Process reachableProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId, "org.kde.kdeconnect.device", "isReachable")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ loader.deviceData.reachable = busctlData(text);
+
+ pairingRequestedProc.running = true;
+ }
+ }
+ }
+
+ property Process pairingRequestedProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId, "org.kde.kdeconnect.device", "isPairRequested")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ loader.deviceData.pairRequested = busctlData(text);
+
+ verificationKeyProc.running = true;
+ }
+ }
+ }
+
+ property Process verificationKeyProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId, "org.kde.kdeconnect.device", "verificationKey")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ loader.deviceData.verificationKey = busctlData(text);
+
+ pairedProc.running = true;
+ }
+ }
+ }
+
+ property Process pairedProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId, "org.kde.kdeconnect.device", "isPaired")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ loader.deviceData.paired = busctlData(text);
+
+ if (loader.deviceData.paired)
+ activeNotificationsProc.running = true;
+ else
+ finalize()
+ }
+ }
+ }
+
+ property Process activeNotificationsProc: Process {
+ command: busctlCall("/modules/kdeconnect/devices/" + loader.deviceId + "/notifications", "org.kde.kdeconnect.device.notifications", "activeNotifications");
+ stdout: StdioCollector {
+ onStreamFinished: {
+ let ids = busctlData(text);
+ loader.deviceData.notificationIds = Array.isArray(ids) ? ids : [];
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ loader.notificationsQueryFailed = exitCode !== 0;
+ cellularNetworkTypeProc.running = true;
+ }
+ }
+
+ property Process cellularNetworkTypeProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId + "/connectivity_report", "org.kde.kdeconnect.device.connectivity_report", "cellularNetworkType")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ loader.deviceData.cellularNetworkType = busctlData(text);
+ cellularNetworkStrengthProc.running = true;
+ }
+ }
+ }
+
+ property Process cellularNetworkStrengthProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId + "/connectivity_report", "org.kde.kdeconnect.device.connectivity_report", "cellularNetworkStrength")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const strength = busctlData(text);
+ loader.deviceData.cellularNetworkStrength = strength;
+ isChargingProc.running = true;
+ }
+ }
+ }
+
+ property Process isChargingProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId + "/battery", "org.kde.kdeconnect.device.battery", "isCharging")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ loader.deviceData.charging = busctlData(text);
+ batteryProc.running = true;
+ }
+ }
+ }
+
+ property Process batteryProc: Process {
+ command: busctlGet("/modules/kdeconnect/devices/" + loader.deviceId + "/battery", "org.kde.kdeconnect.device.battery", "charge")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const charge = busctlData(text);
+ if (!isNaN(charge)) {
+ loader.deviceData.battery = charge;
+ }
+
+ finalize();
+ }
+ }
+ }
+
+ function mergePreviousDeviceData() {
+ const previousDevice = root.devices.find(device => device.id === loader.deviceId);
+ if (!previousDevice)
+ return;
+
+ if (root.shouldKeepPreviousPairedState(
+ loader.deviceId,
+ loader.deviceData.paired,
+ loader.deviceData.pairRequested,
+ loader.deviceData.verificationKey,
+ Boolean(previousDevice.paired))) {
+ loader.deviceData.paired = true;
+ if (!loader.deviceData.pairRequested)
+ loader.deviceData.pairRequested = Boolean(previousDevice.pairRequested);
+ if (String(loader.deviceData.verificationKey || "").trim() === ""
+ && String(previousDevice.verificationKey || "").trim() !== "") {
+ loader.deviceData.verificationKey = previousDevice.verificationKey;
+ }
+ }
+
+ if (Number(loader.deviceData.battery) < 0 && Number(previousDevice.battery) >= 0) {
+ loader.deviceData.battery = previousDevice.battery;
+ loader.deviceData.charging = previousDevice.charging;
+ }
+
+ if (String(loader.deviceData.cellularNetworkType || "").trim() === ""
+ && String(previousDevice.cellularNetworkType || "").trim() !== "") {
+ loader.deviceData.cellularNetworkType = previousDevice.cellularNetworkType;
+ }
+
+ if (Number(loader.deviceData.cellularNetworkStrength) < 0
+ && Number(previousDevice.cellularNetworkStrength) >= 0) {
+ loader.deviceData.cellularNetworkStrength = previousDevice.cellularNetworkStrength;
+ }
+
+ if (loader.notificationsQueryFailed && Array.isArray(previousDevice.notificationIds))
+ loader.deviceData.notificationIds = previousDevice.notificationIds.slice(0);
+ }
+
+ function finalize() {
+ if (loader.refreshGeneration !== root.deviceRefreshGeneration) {
+ loader.destroy();
+ return;
+ }
+
+ mergePreviousDeviceData();
+ root.notePairedObservation(loader.deviceId, loader.deviceData.paired);
+ root.pendingDevices = root.pendingDevices.concat([loader.deviceData]);
+
+ if (root.pendingDevices.length === root.pendingDeviceCount) {
+ let newDevices = root.pendingDevices
+ newDevices.sort((a, b) => a.name.localeCompare(b.name))
+
+ let prevMainDevice = root.devices.find((device) => device.id === root.mainDeviceId);
+ let newMainDevice = newDevices.find((device) => device.id === root.mainDeviceId);
+
+ let deviceNotReachableAnymore =
+ prevMainDevice === undefined ||
+ (
+ (prevMainDevice?.reachable ?? false) &&
+ !(newMainDevice?.reachable ?? false)
+ ) ||
+ (
+ (prevMainDevice?.paired ?? false) &&
+ !(newMainDevice?.paired ?? false)
+ )
+
+ root.devices = newDevices
+ root.pendingDevices = []
+ root.deviceRefreshInProgress = false;
+ updateMainDevice(deviceNotReachableAnymore);
+ }
+
+ loader.destroy();
+ }
+ }
+ }
+
+ // FindMyPhone component
+ property Component findMyPhoneComponent: Component {
+ Process {
+ id: proc
+ property string deviceId: ""
+ command: busctlCall("/modules/kdeconnect/devices/" + deviceId + "/findmyphone", "org.kde.kdeconnect.device.findmyphone", "ring")
+ stdout: StdioCollector {
+ onStreamFinished: proc.destroy()
+ }
+ }
+ }
+
+ // SFTP Browse component
+ property Component browseFilesComponent: Component {
+ Process {
+ id: mountProc
+ property string deviceId: ""
+ property string stderrText: ""
+ command: busctlCall("/modules/kdeconnect/devices/" + deviceId + "/sftp", "org.kde.kdeconnect.device.sftp", "mountAndWait")
+ stdout: StdioCollector {
+ onStreamFinished: rootDirProc.running = true
+ }
+ stderr: StdioCollector {
+ onStreamFinished: {
+ mountProc.stderrText = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0) {
+ root.notifyProcessFailure("Browse device files", mountProc.deviceId, mountProc.stderrText, exitCode);
+ mountProc.destroy();
+ }
+ }
+
+ property Process rootDirProc: Process {
+ property string stderrText: ""
+ command: busctlCall("/modules/kdeconnect/devices/" + mountProc.deviceId + "/sftp", "org.kde.kdeconnect.device.sftp", "getDirectories")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const dirs = busctlData(text);
+ const directoryEntry = Array.isArray(dirs) && dirs.length > 0 && dirs[0] && typeof dirs[0] === "object"
+ ? dirs[0]
+ : null;
+ const path = directoryEntry ? String(Object.keys(directoryEntry)[0] || "").trim() : "";
+ if (path === "") {
+ root.notifyProcessFailure("Browse device files", mountProc.deviceId, "No SFTP directories were returned.", 0);
+ mountProc.destroy();
+ return;
+ }
+
+ if (!Qt.openUrlExternally(root.normalizedFileShareUrl(path))) {
+ root.notifyProcessFailure("Browse device files", mountProc.deviceId, "Failed to open the file manager for " + path + ".", 0);
+ }
+
+ mountProc.destroy();
+ }
+ }
+ stderr: StdioCollector {
+ onStreamFinished: {
+ rootDirProc.stderrText = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0) {
+ root.notifyProcessFailure("Browse device files", mountProc.deviceId, rootDirProc.stderrText, exitCode);
+ mountProc.destroy();
+ }
+ }
+ }
+ }
+ }
+
+ // Request Pairing Component
+ property Component requestPairingComponent: Component {
+ Process {
+ id: proc
+ property string deviceId: ""
+ command: busctlCall("/modules/kdeconnect/devices/" + deviceId, "org.kde.kdeconnect.device", "requestPairing")
+ stdout: StdioCollector {
+ onStreamFinished: proc.destroy()
+ }
+ }
+ }
+
+ // Unpairing Component
+ property Component unpairingComponent: Component {
+ Process {
+ id: proc
+ property string deviceId: ""
+ command: busctlCall("/modules/kdeconnect/devices/" + deviceId, "org.kde.kdeconnect.device", "unpair")
+ stdout: StdioCollector {
+ onStreamFinished: {
+ KDEConnect.refreshDevices()
+ proc.destroy()
+ }
+ }
+ }
+ }
+
+ // Wake up Device Component
+ property Component wakeUpDeviceComponent: Component {
+ Process {
+ id: proc
+ property string deviceId: ""
+ command: busctlCall("/modules/kdeconnect/devices/" + deviceId + "/remotecontrol", "org.kde.kdeconnect.device.remotecontrol", "sendCommand", [ "a{sv}", "1", "singleclick", "b", "true" ])
+ stdout: StdioCollector {
+ onStreamFinished: {
+ KDEConnect.refreshDevices()
+ proc.destroy()
+ }
+ }
+ }
+ }
+
+ // Share file component
+ property Component shareComponent: Component {
+ Process {
+ id: proc
+ property string deviceId: ""
+ property string fileUrl: ""
+ property string stderrText: ""
+ command: busctlCall(
+ "/modules/kdeconnect/devices/" + deviceId + "/share",
+ "org.kde.kdeconnect.device.share",
+ "shareUrls",
+ [ "as", "1", fileUrl ]
+ )
+ stdout: StdioCollector {}
+ stderr: StdioCollector {
+ onStreamFinished: {
+ proc.stderrText = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0)
+ root.notifyProcessFailure("Send file", proc.deviceId, proc.stderrText, exitCode);
+
+ proc.destroy();
+ }
+ }
+ }
+
+ property Process adbQueuedProc: Process {
+ id: adbQueuedProc
+ running: false
+ command: root.adbCommand(root.adbQueuedSerial, root.adbQueuedArgs)
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.adbQueuedStdout = text.trim();
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.adbQueuedStderr = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ const commandKind = root.adbQueuedKind;
+ const commandSerial = root.adbQueuedSerial;
+ const stdoutText = root.adbQueuedStdout;
+ const stderrText = root.adbQueuedStderr;
+
+ if (commandKind === "display-info") {
+ root.adbDisplayInfoStdout = stdoutText;
+ root.adbDisplayInfoStderr = stderrText;
+
+ if (exitCode === 0) {
+ const match = stdoutText.match(/(\d+)x(\d+)/);
+ if (match) {
+ root.adbScreenWidth = Number(match[1]);
+ root.adbScreenHeight = Number(match[2]);
+ root.adbScreenSerial = commandSerial;
+ root.adbScreenError = "";
+ Logger.i("KDEConnect", "ADB screen size:", root.adbScreenWidth, "x", root.adbScreenHeight, "for", root.adbScreenSerial);
+ } else {
+ root.adbScreenWidth = 0;
+ root.adbScreenHeight = 0;
+ root.adbScreenSerial = "";
+ root.adbScreenError = stdoutText !== "" ? stdoutText : "unable_to_parse_wm_size";
+ Logger.w("KDEConnect", "Could not parse adb wm size output:", stdoutText);
+ }
+ } else {
+ root.adbScreenWidth = 0;
+ root.adbScreenHeight = 0;
+ root.adbScreenSerial = "";
+ root.adbScreenError = stderrText !== "" ? stderrText : ("adb wm size exited with code " + exitCode);
+ Logger.w("KDEConnect", "adb wm size failed:", root.adbScreenError);
+ }
+
+ root.adbDisplayInfoSerial = "";
+ } else if (commandKind === "screen-state") {
+ root.adbScreenStateRaw = stdoutText;
+
+ if (exitCode === 0) {
+ const interactiveMatch = stdoutText.match(/(?:^|\n)interactive=(true|false)/);
+ const lockedMatch = stdoutText.match(/(?:^|\n)locked=(true|false|unknown)/);
+ const unlockNeededMatch = stdoutText.match(/(?:^|\n)unlockNeeded=(true|false)/);
+
+ root.adbScreenInteractive = interactiveMatch ? interactiveMatch[1] === "true" : false;
+ root.adbScreenLockState = lockedMatch ? lockedMatch[1] : "unknown";
+ root.adbUnlockNeeded = unlockNeededMatch ? unlockNeededMatch[1] === "true" : true;
+ root.adbScreenStateKnownSerial = commandSerial;
+ root.adbScreenStateError = "";
+ root.adbScreenStateRefreshed(commandSerial, root.adbUnlockNeeded, root.adbScreenInteractive, root.adbScreenLockState);
+ Logger.i("KDEConnect", "ADB screen state:",
+ "serial=" + commandSerial,
+ "interactive=" + root.adbScreenInteractive,
+ "locked=" + root.adbScreenLockState,
+ "unlockNeeded=" + root.adbUnlockNeeded);
+ } else {
+ root.adbScreenStateKnownSerial = "";
+ root.adbScreenInteractive = false;
+ root.adbScreenLockState = "unknown";
+ root.adbUnlockNeeded = true;
+ root.adbScreenStateError = stderrText !== "" ? stderrText : ("adb screen state exited with code " + exitCode);
+ Logger.w("KDEConnect", "adb screen state failed:", root.adbScreenStateError);
+ }
+
+ root.adbScreenStateSerial = "";
+ } else if (commandKind === "screen-timeout") {
+ root.adbScreenTimeoutRaw = stdoutText;
+
+ if (exitCode === 0) {
+ root.adbScreenTimeoutValue = String(stdoutText || "").trim();
+ root.adbScreenTimeoutKnownSerial = commandSerial;
+ root.adbScreenTimeoutError = "";
+ root.adbScreenTimeoutRead(commandSerial, root.adbScreenTimeoutValue, true);
+ Logger.i("KDEConnect", "ADB screen timeout:",
+ "serial=" + commandSerial,
+ "value=" + root.adbScreenTimeoutValue);
+ } else {
+ root.adbScreenTimeoutKnownSerial = "";
+ root.adbScreenTimeoutValue = "";
+ root.adbScreenTimeoutError = stderrText !== "" ? stderrText : ("adb screen timeout exited with code " + exitCode);
+ root.adbScreenTimeoutRead(commandSerial, "", false);
+ Logger.w("KDEConnect", "adb screen timeout failed:", root.adbScreenTimeoutError);
+ }
+
+ root.adbScreenTimeoutSerial = "";
+ } else if (commandKind === "screen-brightness") {
+ root.adbScreenBrightnessRaw = stdoutText;
+
+ if (exitCode === 0) {
+ const brightnessMatch = stdoutText.match(/(?:^|\n)brightness=([^\n]*)/);
+ const modeMatch = stdoutText.match(/(?:^|\n)mode=([^\n]*)/);
+ root.adbScreenBrightnessValue = brightnessMatch ? String(brightnessMatch[1] || "").trim() : "";
+ root.adbScreenBrightnessMode = modeMatch ? String(modeMatch[1] || "").trim() : "";
+ root.adbScreenBrightnessKnownSerial = commandSerial;
+ root.adbScreenBrightnessError = "";
+ root.adbScreenBrightnessRead(commandSerial, root.adbScreenBrightnessMode, root.adbScreenBrightnessValue, true);
+ Logger.i("KDEConnect", "ADB screen brightness:",
+ "serial=" + commandSerial,
+ "mode=" + root.adbScreenBrightnessMode,
+ "brightness=" + root.adbScreenBrightnessValue);
+ } else {
+ root.adbScreenBrightnessKnownSerial = "";
+ root.adbScreenBrightnessValue = "";
+ root.adbScreenBrightnessMode = "";
+ root.adbScreenBrightnessError = stderrText !== "" ? stderrText : ("adb screen brightness exited with code " + exitCode);
+ root.adbScreenBrightnessRead(commandSerial, "", "", false);
+ Logger.w("KDEConnect", "adb screen brightness failed:", root.adbScreenBrightnessError);
+ }
+
+ root.adbScreenBrightnessSerial = "";
+ } else if (exitCode !== 0) {
+ Logger.w("KDEConnect", "ADB input command failed:",
+ "kind=" + commandKind,
+ "serial=" + commandSerial,
+ "exitCode=" + exitCode,
+ "stderr=" + stderrText);
+ }
+
+ root.finishCurrentAdbTask();
+ }
+ }
+
+ property Process adbScreenshotProc: Process {
+ id: adbScreenshotProc
+ running: false
+ command: ["sh", "-lc",
+ "file=" + root.shellQuote(root.adbScreenshotPath)
+ + "; dir=$(dirname \"$file\")"
+ + "; mkdir -p \"$dir\""
+ + " && " + root.shellJoinArgs(root.adbCommand(root.adbScreenshotSerial, ["exec-out", "screencap", "-p"]))
+ + " > \"$file\""
+ + " && [ -s \"$file\" ]"
+ + " || { status=$?; rm -f \"$file\" 2>/dev/null || true; exit \"$status\"; }"
+ ]
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.adbScreenshotError = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ const outputPath = root.adbScreenshotPath;
+ const errorText = root.adbScreenshotError;
+
+ if (exitCode === 0) {
+ showNoticeWithHistory(
+ "Screenshot Saved",
+ outputPath,
+ "camera",
+ 3200,
+ savedMediaNotificationOptions("Screenshot Saved", outputPath, "androidconnect-screenshot")
+ );
+ Logger.i("KDEConnect", "ADB screenshot saved:", outputPath);
+ } else {
+ root.notifyActionFailure("Take screenshot", errorText, exitCode);
+ }
+
+ root.adbScreenshotSerial = "";
+ root.adbScreenshotPath = "";
+ root.adbScreenshotError = "";
+ }
+ }
+
+ property Process adbScreenRecordingProc: Process {
+ id: adbScreenRecordingProc
+ running: false
+ command: root.adbCommand(root.adbScreenRecordingSerial, [
+ "shell",
+ "screenrecord",
+ "--bit-rate",
+ "16000000",
+ root.adbScreenRecordingRemotePath
+ ])
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.adbScreenRecordingError = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (root.adbScreenRecordingRemotePath !== "" && root.adbScreenRecordingLocalPath !== "") {
+ root.adbScreenRecordingFinalizeProc.running = true;
+ return;
+ }
+
+ if (exitCode !== 0)
+ root.notifyActionFailure("Screen recording", root.adbScreenRecordingError, exitCode);
+
+ root.adbScreenRecordingSerial = "";
+ root.adbScreenRecordingRemotePath = "";
+ root.adbScreenRecordingLocalPath = "";
+ root.adbScreenRecordingError = "";
+ root.adbScreenRecordingStopRequested = false;
+ }
+ }
+
+ property Process adbScreenRecordingStopProc: Process {
+ id: adbScreenRecordingStopProc
+ running: false
+ command: root.adbCommand(root.adbScreenRecordingSerial, [
+ "shell",
+ "sh",
+ "-c",
+ "pkill -INT -x screenrecord >/dev/null 2>&1"
+ + " || killall -2 screenrecord >/dev/null 2>&1"
+ + " || { pid=$(pidof screenrecord 2>/dev/null | awk '{print $1}'); [ -n \"$pid\" ] && kill -2 \"$pid\" >/dev/null 2>&1; }"
+ ])
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ const details = text.trim();
+ if (details !== "")
+ root.adbScreenRecordingError = details;
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0 && root.adbScreenRecordingProc.running) {
+ root.adbScreenRecordingStopRequested = false;
+ root.notifyActionFailure("Stop screen recording", root.adbScreenRecordingError, exitCode);
+ }
+ }
+ }
+
+ property Process adbScreenRecordingFinalizeProc: Process {
+ id: adbScreenRecordingFinalizeProc
+ running: false
+ command: ["sh", "-lc",
+ "file=" + root.shellQuote(root.adbScreenRecordingLocalPath)
+ + "; remote=" + root.shellQuote(root.adbScreenRecordingRemotePath)
+ + "; dir=$(dirname \"$file\")"
+ + "; mkdir -p \"$dir\""
+ + " && " + root.shellJoinArgs(root.adbCommand(root.adbScreenRecordingSerial, ["pull", root.adbScreenRecordingRemotePath, root.adbScreenRecordingLocalPath])) + " >/dev/null"
+ + " && [ -s \"$file\" ]"
+ + " && " + root.shellJoinArgs(root.adbCommand(root.adbScreenRecordingSerial, ["shell", "rm", "-f", root.adbScreenRecordingRemotePath])) + " >/dev/null 2>&1"
+ + " || { status=$?; rm -f \"$file\" 2>/dev/null || true; exit \"$status\"; }"
+ ]
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ const details = text.trim();
+ if (details !== "")
+ root.adbScreenRecordingError = details;
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ const outputPath = root.adbScreenRecordingLocalPath;
+
+ if (exitCode === 0) {
+ showNoticeWithHistory(
+ "Screen Recording Saved",
+ outputPath,
+ "video",
+ 3200,
+ savedMediaNotificationOptions("Screen Recording Saved", outputPath, "androidconnect-recording")
+ );
+ Logger.i("KDEConnect", "ADB screen recording saved:", outputPath);
+ } else {
+ root.notifyActionFailure("Screen recording", root.adbScreenRecordingError, exitCode);
+ }
+
+ root.adbScreenRecordingSerial = "";
+ root.adbScreenRecordingRemotePath = "";
+ root.adbScreenRecordingLocalPath = "";
+ root.adbScreenRecordingError = "";
+ root.adbScreenRecordingStopRequested = false;
+ }
+ }
+
+ property Process scrcpyPreLaunchProc: Process {
+ id: scrcpyPreLaunchProc
+ running: false
+ command: root.buildScrcpyPreLaunchCommand(root.scrcpyActiveSerial, root.scrcpyFeedDevicePath)
+
+ stdout: StdioCollector {}
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.scrcpyLastStderr = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (root.scrcpyStopRequested) {
+ root.scrcpyLaunching = false;
+ root.scrcpyStopRequested = false;
+ root.scrcpyCommandArgs = [];
+ root.scrcpyPendingCommandArgs = [];
+ root.scrcpyDeviceId = "";
+ root.scrcpyFeedDevicePath = "";
+ root.scrcpyActiveSerial = "";
+ root.scrcpyLaunchStartedAtMs = 0;
+ Logger.i("KDEConnect", "Stopped scrcpy launch before process start");
+ return;
+ }
+
+ if (exitCode !== 0) {
+ root.scrcpyLaunching = false;
+ root.scrcpyLaunchError = root.scrcpyLastStderr !== ""
+ ? root.scrcpyLastStderr
+ : ("scrcpy pre-launch failed with code " + exitCode);
+ root.scrcpyPendingCommandArgs = [];
+ Logger.e("KDEConnect", "scrcpy pre-launch failed:", root.scrcpyLaunchError);
+ return;
+ }
+
+ root.scrcpyCommandArgs = Array.isArray(root.scrcpyPendingCommandArgs)
+ ? root.scrcpyPendingCommandArgs
+ : [];
+ root.scrcpyPendingCommandArgs = [];
+ Logger.i("KDEConnect", "Launching scrcpy session:",
+ "deviceId=" + root.scrcpyDeviceId,
+ "serial=" + (root.isUsbSelectionSerial(root.scrcpyActiveSerial) ? "usb" : root.scrcpyActiveSerial),
+ "program=" + String(root.scrcpyCommandArgs[0] || ""));
+ root.scrcpySessionProc.running = true;
+ }
+ }
+
+ property Process scrcpySessionProc: Process {
+ id: scrcpySessionProc
+ running: false
+ command: root.scrcpyCommandArgs
+
+ stdout: StdioCollector {}
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.scrcpyLastStderr = text.trim();
+ }
+ }
+
+ onStarted: {
+ root.scrcpyLaunching = false;
+ root.adbScreenError = "";
+ Logger.i("KDEConnect", "Started scrcpy session for device:", root.scrcpyDeviceId);
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ root.scrcpyLaunching = false;
+ Logger.i("KDEConnect", "scrcpy session exited:",
+ "exitCode=" + exitCode,
+ "stopRequested=" + root.scrcpyStopRequested,
+ "stderr=" + (root.scrcpyLastStderr || ""));
+
+ if (!root.scrcpyStopRequested && exitCode !== 0) {
+ const rawError = root.scrcpyLastStderr !== "" ? root.scrcpyLastStderr : ("scrcpy exited with code " + exitCode);
+ if (root.scrcpyFeedDevicePath !== ""
+ && (rawError.indexOf("Failed to open output") !== -1
+ || rawError.indexOf("Failed to write header") !== -1
+ || rawError.indexOf("Demuxer") !== -1)) {
+ root.scrcpyLaunchError = "V4L2 sink " + root.scrcpyFeedDevicePath
+ + " is unavailable. Recreate the v4l2loopback device node and try again.";
+ } else {
+ root.scrcpyLaunchError = rawError;
+ }
+ Logger.e("KDEConnect", "scrcpy session exited unexpectedly:", root.scrcpyLaunchError);
+ }
+
+ if (root.scrcpyStopRequested) {
+ Logger.i("KDEConnect", "Stopped scrcpy session for device:", root.scrcpyDeviceId);
+ }
+
+ root.scrcpyStopRequested = false;
+ root.scrcpyCommandArgs = [];
+ root.scrcpyPendingCommandArgs = [];
+ root.scrcpyDeviceId = "";
+ root.scrcpyFeedDevicePath = "";
+ root.scrcpyActiveSerial = "";
+ root.scrcpyLaunchStartedAtMs = 0;
+ root.adbScreenWidth = 0;
+ root.adbScreenHeight = 0;
+ root.adbScreenSerial = "";
+ root.adbScreenStateSerial = "";
+ root.adbScreenStateKnownSerial = "";
+ root.adbScreenStateRaw = "";
+ root.adbScreenStateError = "";
+ root.adbScreenLockState = "unknown";
+ root.adbScreenInteractive = false;
+ root.adbUnlockNeeded = true;
+ root.adbScreenTimeoutSerial = "";
+ root.adbScreenTimeoutKnownSerial = "";
+ root.adbScreenTimeoutRaw = "";
+ root.adbScreenTimeoutError = "";
+ root.adbScreenTimeoutValue = "";
+ root.adbScreenBrightnessSerial = "";
+ root.adbScreenBrightnessKnownSerial = "";
+ root.adbScreenBrightnessRaw = "";
+ root.adbScreenBrightnessError = "";
+ root.adbScreenBrightnessValue = "";
+ root.adbScreenBrightnessMode = "";
+ root.adbDisplayInfoSerial = "";
+ root.adbDisplayInfoStdout = "";
+ root.adbDisplayInfoStderr = "";
+ root.adbQueuedSerial = "";
+ root.adbQueuedArgs = [];
+ root.adbQueuedKind = "";
+ root.adbQueuedStdout = "";
+ root.adbQueuedStderr = "";
+ root.adbCommandQueue = [];
+ }
+ }
+
+ property Process scrcpyCleanupProc: Process {
+ id: scrcpyCleanupProc
+ running: false
+ command: ["sh", "-lc",
+ "device=" + root.shellQuote(root.scrcpyCleanupFeedDevicePath)
+ + "; for pid in $(pgrep -x scrcpy || true); do"
+ + " cmd=$(tr '\\0' '\\n' /dev/null || true)"
+ + "; if [ -n \"$device\" ] && printf '%s\\n' \"$cmd\" | grep -Fqx -- \"--v4l2-sink=$device\"; then"
+ + " kill -TERM \"$pid\" 2>/dev/null || true; continue"
+ + "; fi"
+ + "; done"
+ + "; sleep 0.35"
+ + "; for pid in $(pgrep -x scrcpy || true); do"
+ + " cmd=$(tr '\\0' '\\n' /dev/null || true)"
+ + "; if [ -n \"$device\" ] && printf '%s\\n' \"$cmd\" | grep -Fqx -- \"--v4l2-sink=$device\"; then"
+ + " kill -KILL \"$pid\" 2>/dev/null || true; continue"
+ + "; fi"
+ + "; done"
+ ]
+
+ onExited: (exitCode, exitStatus) => {
+ root.scrcpyCleanupFeedDevicePath = "";
+ }
+ }
+
+ property Process wirelessAdbProc: Process {
+ id: wirelessAdbProc
+ running: false
+ command: root.wirelessAdbCommandArgs
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.wirelessAdbLastStdout = text.trim();
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.wirelessAdbLastStderr = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ root.wirelessAdbBusy = false;
+
+ const success = exitCode === 0;
+ const message = success
+ ? (root.wirelessAdbLastStdout !== "" ? root.wirelessAdbLastStdout : "ok")
+ : (root.wirelessAdbLastStderr !== "" ? root.wirelessAdbLastStderr : ("command exited with code " + exitCode));
+
+ root.wirelessAdbCommandArgs = [];
+ root.wirelessAdbFinished(success, message);
+ }
+ }
+
+ property Process adbDevicesProc: Process {
+ id: adbDevicesProc
+ running: false
+ command: ["adb", "devices"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.adbDevicesStdout = text.trim();
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.adbDevicesStderr = text.trim();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ const connectedSerials = [];
+ const deviceStates = ({});
+ let hasUsbTransport = false;
+
+ if (exitCode === 0 && root.adbDevicesStdout !== "") {
+ const lines = root.adbDevicesStdout.split(/\r?\n/);
+ for (let i = 0; i < lines.length; ++i) {
+ const trimmedLine = String(lines[i] || "").trim();
+ if (trimmedLine === ""
+ || trimmedLine === "List of devices attached"
+ || trimmedLine.indexOf("* daemon") === 0)
+ continue;
+
+ const columns = trimmedLine.split(/\s+/);
+ const serial = String(columns[0] || "").trim();
+ const state = String(columns[1] || "").trim();
+
+ if (serial !== "")
+ deviceStates[serial] = state;
+
+ if (serial === "" || state !== "device")
+ continue;
+
+ connectedSerials.push(serial);
+ if (serial.indexOf(":") === -1)
+ hasUsbTransport = true;
+ }
+ } else if (exitCode !== 0) {
+ Logger.w("KDEConnect", "Failed to refresh adb devices:",
+ "exitCode=" + exitCode,
+ "stderr=" + root.adbDevicesStderr);
+ }
+
+ root.adbDevicesExitCode = exitCode;
+ root.adbDeviceStates = deviceStates;
+ root.adbConnectedSerials = connectedSerials;
+ root.adbHasUsbTransport = hasUsbTransport;
+ root.adbDevicesRefreshed();
+ }
+ }
+
+ Component.onDestruction: {
+ stopScrcpySession();
+ }
+
+ // Periodic refresh timer
+ property Timer refreshTimer: Timer {
+ interval: root.refreshIntervalMs
+ running: true
+ repeat: true
+ onTriggered: root.checkDaemon()
+ }
+}
diff --git a/androidconnect/Services/KDEConnectUtils.qml b/androidconnect/Services/KDEConnectUtils.qml
new file mode 100644
index 000000000..158e8a1ba
--- /dev/null
+++ b/androidconnect/Services/KDEConnectUtils.qml
@@ -0,0 +1,40 @@
+pragma Singleton
+
+import QtQuick
+
+QtObject {
+ function getConnectionStateIcon(device, daemonAvailable) {
+ if (!daemonAvailable)
+ return "exclamation-circle";
+
+ if (device === null || !device.reachable)
+ return "device-mobile-off";
+
+ if (device.battery >= 0 && device.battery < 10)
+ return "device-mobile-exclamation"
+
+ if (device.notificationIds.length > 0)
+ return "device-mobile-message";
+ else if (device.charging)
+ return "device-mobile-bolt";
+ else
+ return "device-mobile";
+ }
+
+ // Returns raw state keys for translation
+ function getConnectionStateKey(device, daemonAvailable) {
+ if (!daemonAvailable)
+ return "control_center.state.unavailable";
+
+ if (device === null)
+ return "control_center.state.no-device";
+
+ if (!device.reachable)
+ return "control_center.state.disconnected";
+
+ if (!device.paired)
+ return "control_center.state.not-paired";
+
+ return "control_center.state.connected";
+ }
+}
diff --git a/androidconnect/Services/qmldir b/androidconnect/Services/qmldir
new file mode 100644
index 000000000..c9770c0b0
--- /dev/null
+++ b/androidconnect/Services/qmldir
@@ -0,0 +1,2 @@
+singleton KDEConnect 1.0 KDEConnect.qml
+singleton KDEConnectUtils 1.0 KDEConnectUtils.qml
diff --git a/androidconnect/Settings.qml b/androidconnect/Settings.qml
new file mode 100644
index 000000000..bdcbc015d
--- /dev/null
+++ b/androidconnect/Settings.qml
@@ -0,0 +1,35 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+ColumnLayout {
+ id: root
+
+ property var pluginApi: null
+
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+
+ property string iconColor: cfg.iconColor ?? defaults.iconColor ?? "none"
+
+ spacing: Style.marginL
+
+ NColorChoice {
+ Layout.fillWidth: true
+ label: pluginApi?.tr("settings.iconColor.label")
+ description: pluginApi?.tr("settings.iconColor.desc")
+ currentKey: iconColor
+ onSelected: key => iconColor = key
+ }
+
+ function saveSettings() {
+ if (!pluginApi) {
+ Logger.e("KDEConnect", "Cannot save settings: pluginApi is null");
+ return;
+ }
+
+ pluginApi.pluginSettings.iconColor = iconColor;
+ pluginApi.saveSettings();
+ }
+}
diff --git a/androidconnect/i18n/de.json b/androidconnect/i18n/de.json
new file mode 100644
index 000000000..6d5376c20
--- /dev/null
+++ b/androidconnect/i18n/de.json
@@ -0,0 +1,55 @@
+{
+ "panel": {
+ "title": "Verbundene Geräte",
+ "signal": {
+ "very-weak": "Sehr schwach",
+ "weak": "Schwach",
+ "fair": "Okay",
+ "good": "Gut",
+ "excellent": "Ausgezeichnet"
+ },
+ "unknown": "Unbekannt",
+ "card": {
+ "battery": "Akkustand",
+ "network": "Netzwerk",
+ "signal-strength": "Signalstärke",
+ "notifications": "Benachrichtigungen"
+ },
+ "other-devices": "Andere Geräte",
+ "send-file-picker": "Datei zum Senden an Gerät auswählen",
+ "send-file": "Datei senden",
+ "browse-files": "Dateien auf Gerät durchsuchen",
+ "find-device": "Mein Gerät suchen",
+ "pair": "Mit Gerät koppeln",
+ "unpair": "Gerät entkoppeln",
+ "kdeconnect-error": {
+ "no-devices": "Kein Gerät mit KDE Connect gefunden",
+ "unavailable-title": "kdeconnectd scheint nicht zu laufen!",
+ "unavailable-desc": "Sicherstellen, dass die KDE Connect-Anwendung auf Ihrem System installiert ist und dass der kdeconnectd-Daemon gestartet wurde",
+ "device-unavailable": "Das Gerät ist derzeit nicht verfügbar."
+ },
+ "busctl-error": {
+ "unavailable-title": "busctl kann nicht gefunden werden!",
+ "unavailable-desc": "Stelle sicher, dass busctl (teil von systemd) auf deinem System installiert ist"
+ }
+ },
+ "bar": {
+ "tooltip": "Verbundene Geräte"
+ },
+ "control_center": {
+ "state-label": "Status",
+ "state": {
+ "connected": "Verbunden",
+ "disconnected": "Getrennt",
+ "unavailable": "Nicht verfügbar",
+ "no-device": "Kein Gerät",
+ "not-paired": "Nicht gekoppelt"
+ }
+ },
+ "settings": {
+ "no-device-connected-hide": {
+ "label": "Verstecken wenn nicht verbunden",
+ "description": "Verstecke den Knopf in der Leiste, wenn kein Gerät verbunden ist"
+ }
+ }
+}
diff --git a/androidconnect/i18n/en.json b/androidconnect/i18n/en.json
new file mode 100644
index 000000000..8ecab5e6a
--- /dev/null
+++ b/androidconnect/i18n/en.json
@@ -0,0 +1,155 @@
+{
+ "panel": {
+ "title": "Connected Devices",
+ "phone": {
+ "wake-title": "Wake Device",
+ "wake-description": "Click to wake the device"
+ },
+ "signal": {
+ "very-weak": "Very Weak",
+ "weak": "Weak",
+ "fair": "Fair",
+ "good": "Good",
+ "excellent": "Excellent"
+ },
+ "unknown": "Unknown",
+ "card": {
+ "battery": "Battery",
+ "network": "Network",
+ "signal-strength": "Signal Strength",
+ "notifications": "Notifications",
+ "battery-charging": "Charging now",
+ "battery-discharging": "Running on battery",
+ "network-description": "Reported by the phone connectivity plugin.",
+ "signal-description": "No live cellular reading is available.",
+ "notifications-clear": "No active alerts",
+ "notifications-active": "Active notifications on the device"
+ },
+ "other-devices": "Other Devices",
+ "send-file-picker": "Pick file to send to device",
+ "send-file": "Send File",
+ "browse-device": "Browse Device Files",
+ "find-device": "Find my Device",
+ "quick-actions": {
+ "title": "Quick actions",
+ "description": "Common phone tools without leaving the panel.",
+ "wireless-adb-title": "Wireless ADB",
+ "wireless-adb-description": "Pair or reconnect adb for taps and key input.",
+ "browse-description": "Open the phone storage in your file manager.",
+ "send-description": "Push files directly to the active device.",
+ "find-description": "Make the phone ring so you can find it."
+ },
+ "remote": {
+ "title": "Live control",
+ "description": "Tap, drag, and use Android navigation directly from the panel.",
+ "connected-description": "KDE Connect is active. Quick actions and remote control stay in one place.",
+ "connected-badge": "Connected",
+ "session-badge": "Session active",
+ "ready-badge": "Remote ready"
+ },
+ "wireless-adb": {
+ "tooltip": "Open Wireless ADB tools",
+ "busy-tooltip": "Wireless ADB command is running",
+ "success-title": "Wireless ADB",
+ "success-description": "ADB over TCP/IP enabled",
+ "error-title": "Wireless ADB",
+ "missing-command-description": "The built-in Wireless ADB helper could not start.",
+ "missing-pair-parameters-description": "Enter the phone IP, pairing port, and pairing code",
+ "missing-connect-parameters-description": "Enter the phone IP and connect port",
+ "dialog-title": "Wireless ADB",
+ "dialog-description": "Use Android's Wireless debugging screen to pair with a code, then connect to the adb port.",
+ "qr-step-title": "1. Pair with QR code",
+ "qr-section-title": "Pair with QR code",
+ "qr-section-description": "On the phone, open Wireless debugging and choose Pair device with QR code, then scan this image.",
+ "qr-placeholder": "Tap Start QR to generate a pairing code.",
+ "qr-helper-description": "The plugin will wait for the scan, pair automatically, then connect ADB and save the resolved host and port.",
+ "qr-footer-description": "Leave this popup open until the phone finishes the scan.",
+ "qr-button": "Start QR Pairing",
+ "qr-refresh-button": "Refresh QR",
+ "qr-waiting-button": "Waiting for scan...",
+ "qr-waiting-description": "Waiting for the phone to scan the QR code and publish its pairing service.",
+ "qr-success-description": "Wireless ADB paired and connected from the QR code.",
+ "qr-generate-error-description": "Failed to generate the Wireless ADB QR code.",
+ "missing-qr-parameters-description": "Generate a fresh Wireless ADB QR code and try again.",
+ "pair-step-title": "2. Pair with code",
+ "pair-section-title": "Pair with code",
+ "pair-section-description": "On the phone, open Wireless debugging and choose Pair device with pairing code.",
+ "host-label": "Phone IP",
+ "pair-port-label": "Pair port",
+ "pair-code-label": "Pairing code",
+ "pair-button": "Pair",
+ "connect-step-title": "3. Connect after pairing",
+ "connect-section-title": "Connect after pairing",
+ "connect-section-description": "Use the adb port shown on the phone after pairing.",
+ "connect-host-label": "Phone IP",
+ "connect-port-label": "ADB port",
+ "connect-button": "Connect",
+ "legacy-section-title": "Legacy TCP/IP helper",
+ "legacy-section-description": "This runs the configured helper command, such as adb tcpip 5555, for older or already-authorized ADB flows.",
+ "legacy-button": "Run helper",
+ "running-status": "Running adb command...",
+ "running-description": "Keep this panel open until adb finishes.",
+ "status-title": "Last result"
+ },
+ "embedded-mirror": {
+ "starting-title": "Starting Embedded Mirror",
+ "starting-description": "Launching scrcpy and preparing the live feed.",
+ "error-title": "Mirror Error",
+ "ready-title": "Remote Ready",
+ "ready-description": "Tap, drag, and use the Android navigation bar below.",
+ "nav-back": "Back",
+ "nav-home": "Home",
+ "nav-recents": "Recents"
+ },
+ "scrcpy": {
+ "starting-title": "Starting scrcpy",
+ "starting-description": "Preparing the control session",
+ "running-title": "scrcpy Active",
+ "running-description": "Click the phone tile again to stop the session",
+ "ready-title": "Launch scrcpy",
+ "ready-description": "Click to launch phone control",
+ "error-title": "scrcpy Error",
+ "not-configured-title": "scrcpy Not Configured",
+ "not-configured-description": "Set a scrcpy command in the plugin settings",
+ "missing-command-description": "Set a scrcpy command in the plugin settings"
+ },
+ "pair": "Pair with Device",
+ "pair-needed-title": "Pairing Needed",
+ "pair-needed-subtitle": "KDE Connect reported this device as temporarily unpaired.",
+ "pair-requested-title": "Pairing Request Sent",
+ "pair-requested-subtitle": "Approve the request on the phone to restore controls.",
+ "pair-requested": "Confirm the pairing request on the phone. The mirror and device actions will come back automatically after approval.",
+ "pair-description": "This device is temporarily reported as unpaired. Retry pairing here if KDE Connect did not recover on its own after reconnecting.",
+ "pair-waiting": "Waiting for the phone to accept the pairing request.",
+ "unpair": "Unpair Device",
+ "kdeconnect-error": {
+ "no-devices": "No device running KDE Connect found",
+ "unavailable-title": "kdeconnectd does not seem to be running!",
+ "unavailable-desc": "Make sure you've installed the KDE Connect Application on your system and that it has started the kdeconnectd daemon",
+ "device-unavailable": "Device is currently unavailable"
+ },
+ "busctl-error": {
+ "unavailable-title": "busctl cannot be found!",
+ "unavailable-desc": "Make sure busctl (part of systemd) is installed on your system"
+ }
+ },
+ "bar": {
+ "tooltip": "Connected Devices"
+ },
+ "control_center": {
+ "state-label": "State",
+ "state": {
+ "connected": "Connected",
+ "disconnected": "Disconnected",
+ "unavailable": "Unavailable",
+ "no-device": "No device",
+ "not-paired": "Not paired"
+ }
+ },
+ "settings": {
+ "iconColor": {
+ "label": "Widget Icon Color",
+ "desc": "Color of the AndroidConnect bar widget icon"
+ }
+ }
+}
diff --git a/androidconnect/i18n/fr.json b/androidconnect/i18n/fr.json
new file mode 100644
index 000000000..9571791b3
--- /dev/null
+++ b/androidconnect/i18n/fr.json
@@ -0,0 +1,44 @@
+{
+ "panel": {
+ "title": "Appareils connectés",
+ "signal": {
+ "very-weak": "Très faible",
+ "weak": "Faible",
+ "fair": "Moyen",
+ "good": "Bon",
+ "excellent": "Excellent"
+ },
+ "unknown": "Inconnu",
+ "card": {
+ "battery": "Batterie",
+ "network": "Réseau",
+ "signal-strength": "Force du signal",
+ "notifications": "Notifications"
+ },
+ "other-devices": "Autres appareils",
+ "send-file-picker": "Choisir un fichier à envoyer à l'appareil",
+ "send-file": "Envoyer un fichier",
+ "find-device": "Trouver mon appareil",
+ "pair": "Coupler avec l'appareil",
+ "unpair": "Découpler l'appareil",
+ "kdeconnect-error": {
+ "no-devices": "Aucun appareil exécutant KDE Connect trouvé",
+ "unavailable-title": "kdeconnectd ne semble pas être en cours d'exécution !",
+ "unavailable-desc": "Assurez-vous d'avoir installé l'application KDE Connect sur votre système et qu'elle a démarré le démon kdeconnectd",
+ "device-unavailable": "L'appareil est actuellement indisponible"
+ }
+ },
+ "bar": {
+ "tooltip": "Appareils connectés"
+ },
+ "control_center": {
+ "state-label": "État",
+ "state": {
+ "connected": "Connecté",
+ "disconnected": "Déconnecté",
+ "unavailable": "Indisponible",
+ "no-device": "Aucun appareil",
+ "not-paired": "Non couplé"
+ }
+ }
+}
diff --git a/androidconnect/i18n/pt.json b/androidconnect/i18n/pt.json
new file mode 100644
index 000000000..25e252bd2
--- /dev/null
+++ b/androidconnect/i18n/pt.json
@@ -0,0 +1,48 @@
+{
+ "panel": {
+ "title": "Dispositivos Conectados",
+ "signal": {
+ "very-weak": "Muito Fraco",
+ "weak": "Fraco",
+ "fair": "Justo",
+ "good": "Bom",
+ "excellent": "Excelente"
+ },
+ "unknown": "Desconhecido",
+ "card": {
+ "battery": "Bateria",
+ "network": "Rede",
+ "signal-strength": "Intensidade do Signal",
+ "notifications": "Notificações"
+ },
+ "other-devices": "Outros Dispositivos",
+ "send-file-picker": "Selecione o arquivo para enviar ao dispositivo",
+ "send-file": "Enviar Arquivo",
+ "find-device": "Encontrar meu Dispositivo",
+ "pair": "Emparelhar meu Dispositivo",
+ "unpair": "Desemparelhar meu dispositivo",
+ "kdeconnect-error": {
+ "no-devices": "Nenhum dispositivo executando o KDE Connect encontrado",
+ "unavailable-title": "O kdeconnectd parece não estar em execução!",
+ "unavailable-desc": "Certifique-se de ter instalado o aplicativo KDE Connect em seu sistema e de que o daemon kdeconnectd esteja em execução",
+ "device-unavailable": "O dispositivo está indisponível no momento"
+ },
+ "busctl-error": {
+ "unavailable-title": "O busctl não foi encontrado!",
+ "unavailable-desc": "Certifique-se de que o busctl esteja instalado em seu sistema"
+ }
+ },
+ "bar": {
+ "tooltip": "Dispositivos conectados"
+ },
+ "control_center": {
+ "state-label": "Estado",
+ "state": {
+ "connected": "Conectado",
+ "disconnected": "Desconectado",
+ "unavailable": "Indisponível",
+ "no-device": "Nenhum dispositivo",
+ "not-paired": "Não pareado"
+ }
+ }
+}
diff --git a/androidconnect/i18n/ru.json b/androidconnect/i18n/ru.json
new file mode 100644
index 000000000..0d8d1d333
--- /dev/null
+++ b/androidconnect/i18n/ru.json
@@ -0,0 +1,49 @@
+{
+ "panel": {
+ "title": "Подключённые устройства",
+ "signal": {
+ "very-weak": "Очень слабое",
+ "weak": "Слабое",
+ "fair": "Среднее",
+ "good": "Хорошее",
+ "excellent": "Отличное"
+ },
+ "unknown": "Неизвестно",
+ "card": {
+ "battery": "Батарея",
+ "network": "Сеть",
+ "signal-strength": "Качество сигнала",
+ "notifications": "Уведомления"
+ },
+ "other-devices": "Другие устройства",
+ "send-file-picker": "Выберите файл для отправки на устройство",
+ "send-file": "Отправить файл",
+ "browse-device": "Просмотреть файлы на устройстве",
+ "find-device": "Найти моё устройство",
+ "pair": "Сопрячь устройство",
+ "unpair": "Разорвать сопряжение",
+ "kdeconnect-error": {
+ "no-devices": "Устройства с KDE Connect не обнаружены",
+ "unavailable-title": "Похоже, kdeconnectd не запущен!",
+ "unavailable-desc": "Убедитесь, что KDE Connect установлен в системе и служба kdeconnectd работает.",
+ "device-unavailable": "Устройство сейчас недоступно"
+ },
+ "busctl-error": {
+ "unavailable-title": "Не удаётся найти busctl!",
+ "unavailable-desc": "Убедитесь, что busctl установлен в вашей системе"
+ }
+ },
+ "bar": {
+ "tooltip": "Подключённые устройства"
+ },
+ "control_center": {
+ "state-label": "Статус",
+ "state": {
+ "connected": "Подключено",
+ "disconnected": "Не подключено",
+ "unavailable": "Недоступно",
+ "no-device": "Нет устройства",
+ "not-paired": "Нет сопряжения"
+ }
+ }
+}
diff --git a/androidconnect/manifest.json b/androidconnect/manifest.json
new file mode 100644
index 000000000..632820260
--- /dev/null
+++ b/androidconnect/manifest.json
@@ -0,0 +1,40 @@
+{
+ "id": "androidconnect",
+ "name": "AndroidConnect",
+ "version": "1.4.0",
+ "minNoctaliaVersion": "4.4.0",
+ "author": "demencia89",
+ "license": "GPLv2",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "description": "A Noctalia Android device plugin built on top of the original KDE Connect plugin, with embedded scrcpy support.",
+ "tags": [
+ "Bar",
+ "Panel",
+ "Utility",
+ "System"
+ ],
+ "entryPoints": {
+ "main": "Main.qml",
+ "barWidget": "BarWidget.qml",
+ "controlCenterWidget": "ControlCenterWidget.qml",
+ "panel": "Panel.qml",
+ "settings": "Settings.qml"
+ },
+ "dependencies": {
+ "plugins": []
+ },
+ "metadata": {
+ "defaultSettings": {
+ "hideIfNoDeviceConnected": false,
+ "iconColor": "none",
+ "embeddedMirrorAudioEnabled": false,
+ "phoneSizePresetIndex": 0,
+ "phoneSizePresetKey": "small",
+ "phoneSizeStepDirection": 1,
+ "wirelessAdbPairHost": "",
+ "wirelessAdbPairPort": "",
+ "wirelessAdbConnectHost": "",
+ "wirelessAdbConnectPort": ""
+ }
+ }
+}
diff --git a/androidconnect/preview.png b/androidconnect/preview.png
new file mode 100644
index 000000000..27a55906e
Binary files /dev/null and b/androidconnect/preview.png differ