diff --git a/res/values/strings.xml b/res/values/strings.xml
index 8add4548..0890af28 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -393,6 +393,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
Find remote device
Ring your remote device
Ring
+ Virtual Monitor
+ Extend your devices monitors
System volume
Control the system volume of the remote device
diff --git a/src/org/kde/kdeconnect/Backends/BaseLink.java b/src/org/kde/kdeconnect/Backends/BaseLink.java
index cc4dacb1..f2aa634c 100644
--- a/src/org/kde/kdeconnect/Backends/BaseLink.java
+++ b/src/org/kde/kdeconnect/Backends/BaseLink.java
@@ -16,6 +16,8 @@ import org.kde.kdeconnect.DeviceInfo;
import org.kde.kdeconnect.NetworkPacket;
import java.io.IOException;
+import java.net.InetAddress;
+import java.net.SocketAddress;
import java.util.ArrayList;
@@ -43,6 +45,8 @@ public abstract class BaseLink {
return getDeviceInfo().id;
}
+ public InetAddress getDeviceIp() { return null; }
+
public BaseLinkProvider getLinkProvider() {
return linkProvider;
}
diff --git a/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java b/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java
index e3b96ec3..02f21e3c 100644
--- a/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java
+++ b/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java
@@ -27,9 +27,11 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
+import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
+import java.net.SocketAddress;
import java.net.SocketTimeoutException;
import java.nio.channels.NotYetConnectedException;
@@ -256,4 +258,7 @@ public class LanLink extends BaseLink {
packetReceived(np);
}
+ @Override
+ public InetAddress getDeviceIp() { return socket.getInetAddress(); }
+
}
diff --git a/src/org/kde/kdeconnect/Device.kt b/src/org/kde/kdeconnect/Device.kt
index 0e2cc85a..e5e7fbe6 100644
--- a/src/org/kde/kdeconnect/Device.kt
+++ b/src/org/kde/kdeconnect/Device.kt
@@ -46,6 +46,8 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import java.util.concurrent.CopyOnWriteArrayList
import androidx.core.content.edit
+import java.net.InetAddress
+import java.net.SocketAddress
class Device : PacketReceiver {
@@ -647,6 +649,15 @@ class Device : PacketReceiver {
fun removePluginsChangedListener(listener: PluginsChangedListener) = pluginsChangedListeners.remove(listener)
+ fun ipAddress(): InetAddress? {
+ for (link in links) {
+ if (link.deviceIp != null) {
+ return link.deviceIp
+ }
+ }
+ return null
+ }
+
fun disconnect() {
links.forEach(BaseLink::disconnect)
}
diff --git a/src/org/kde/kdeconnect/Helpers/NetworkHelper.kt b/src/org/kde/kdeconnect/Helpers/NetworkHelper.kt
index d7af9a79..33bd3e8a 100644
--- a/src/org/kde/kdeconnect/Helpers/NetworkHelper.kt
+++ b/src/org/kde/kdeconnect/Helpers/NetworkHelper.kt
@@ -19,7 +19,7 @@ object NetworkHelper {
//
// If we run across an interface that has this, we can safely
// ignore it. In fact, it's much safer to do. If we don't, we
- // might get invalid IP adddresses out of it.
+ // might get invalid IP addresses out of it.
@JvmStatic
val localIpAddress: InetAddress?
get() {
@@ -34,7 +34,7 @@ object NetworkHelper {
//
// If we run across an interface that has this, we can safely
// ignore it. In fact, it's much safer to do. If we don't, we
- // might get invalid IP adddresses out of it.
+ // might get invalid IP addresses out of it.
if (intf.displayName.contains("rmnet")) {
continue
}
diff --git a/src/org/kde/kdeconnect/Plugins/VirtualMonitorPlugin/VirtualMonitorPlugin.kt b/src/org/kde/kdeconnect/Plugins/VirtualMonitorPlugin/VirtualMonitorPlugin.kt
new file mode 100644
index 00000000..b976498e
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/VirtualMonitorPlugin/VirtualMonitorPlugin.kt
@@ -0,0 +1,114 @@
+/*
+ * SPDX-FileCopyrightText: 2025 Aleix Pol i Gonzalez
+ *
+ * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+ */
+package org.kde.kdeconnect.Plugins.VirtualMonitorPlugin
+
+import android.content.Intent
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Build
+import android.util.Log
+import android.view.WindowManager
+import android.view.WindowMetrics
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
+import org.json.JSONArray
+import org.json.JSONObject
+import org.kde.kdeconnect.NetworkPacket
+import org.kde.kdeconnect.Plugins.Plugin
+import org.kde.kdeconnect.Plugins.PluginFactory.LoadablePlugin
+import org.kde.kdeconnect_tp.R
+
+const val PACKET_TYPE_VIRTUALMONITOR: String = "kdeconnect.virtualmonitor"
+const val PACKET_TYPE_VIRTUALMONITOR_REQUEST: String = "kdeconnect.virtualmonitor.request"
+
+@LoadablePlugin
+class VirtualMonitorPlugin : Plugin() {
+
+ override val displayName: String
+ get() = context.resources.getString(R.string.pref_plugin_virtualmonitor)
+
+ override val description: String
+ get() = context.resources.getString(R.string.pref_plugin_virtualmonitor_desc)
+
+ private fun openUrlExternally(url: Uri): Boolean {
+ val intent = Intent(Intent.ACTION_VIEW, url)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (intent.resolveActivity(context.packageManager) != null) {
+ context.startActivity(intent)
+ return true
+ } else {
+ return false
+ }
+ }
+
+ override fun onPacketReceived(np: NetworkPacket): Boolean {
+ if (np.type == PACKET_TYPE_VIRTUALMONITOR_REQUEST) {
+ // At least a password is necessary, we have defaults for all other parameters
+ if (!np.has("password")) {
+ Log.e("KDE/VirtualMonitor", "Request invalid, missing password")
+ return false
+ }
+ val addr = device.ipAddress()?.hostAddress
+ if (addr == null) {
+ Log.e("KDE/VirtualMonitor", "Request invalid, no address")
+ return false
+ }
+ val protocol = np.getString("protocol")
+ val username = np.getString("username")
+ val password = np.getString("password")
+ val port = np.getInt("port", -1)
+
+ val url = "$protocol://$username:$password@$addr:$port".toUri()
+
+ Log.i("KDE/VirtualMonitor", "Received request, try connecting to $url")
+
+ if (!openUrlExternally(url)) {
+ Log.e("KDE/VirtualMonitor", "Failed to open $url")
+ val failure = NetworkPacket(PACKET_TYPE_VIRTUALMONITOR).apply {
+ this["failed"] = 0
+ }
+ device.sendPacket(failure)
+ }
+ }
+ return true
+ }
+
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+ override fun onCreate() : Boolean
+ {
+ val windowManager = ContextCompat.getSystemService(context, WindowManager::class.java)
+ assert(windowManager != null);
+ val windowMetrics: WindowMetrics = windowManager!!.currentWindowMetrics
+ if (device.ipAddress() == null) {
+ Log.e("KDE/VirtualMonitor", "No IP address for device, pass.")
+ return false
+ }
+
+ val bounds: Rect = windowMetrics.bounds
+ val np = NetworkPacket(PACKET_TYPE_VIRTUALMONITOR).apply {
+ this["resolutions"] = JSONArray().apply { put(JSONObject().apply {
+ put("resolution", bounds.width().toString() + 'x' + bounds.height())
+ put("scale", windowMetrics.density)
+ }) }
+ this["supports_rdp"] = true
+ this["supports_virt_mon"] = false
+ }
+
+ device.sendPacket(np)
+ return true
+ }
+
+ override fun onUnpairedDevicePacketReceived(np: NetworkPacket): Boolean {
+ return super.onUnpairedDevicePacketReceived(np)
+ }
+
+ override val supportedPacketTypes: Array
+ get() = arrayOf(PACKET_TYPE_VIRTUALMONITOR, PACKET_TYPE_VIRTUALMONITOR_REQUEST)
+
+ override val outgoingPacketTypes: Array
+ get() = arrayOf(PACKET_TYPE_VIRTUALMONITOR)
+}