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 + +![AndroidConnect plugin preview](preview.png) + +### Panel Overview + +![AndroidConnect panel overview](Docs/Screenshots/androidconnect-panel-overview.png) + +### Lock Screen View + +![AndroidConnect lock screen view](Docs/Screenshots/androidconnect-lock-screen.png) + +### Panel Close-up + +![AndroidConnect panel close-up](Docs/Screenshots/androidconnect-panel-closeup.png) + +## 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