From 8f8a09a99a72503675cc2bd17f160fa7386a0f7e Mon Sep 17 00:00:00 2001 From: Albert Vaca Cintora Date: Mon, 28 Aug 2023 21:37:55 +0000 Subject: [PATCH] Add MDNS discovery Uses Android's `NsdManager` to announce a `_kdeconnect._udp` service using MDNS. This is done in addition to sending UDP broadcasts. When we detect a device this way, we send a UDP identity packet to it (identical to the ones we broadcast but sent to a single device). I also added a toggle in settings to disable the UDP broadcasts, so we can test MDNS by itself. --- res/values/strings.xml | 2 + .../Backends/LanBackend/LanLinkProvider.java | 31 ++- .../Backends/LanBackend/MdnsDiscovery.java | 224 ++++++++++++++++++ .../Backends/LanBackend/NsdResolveQueue.java | 91 +++++++ .../kde/kdeconnect/Helpers/NetworkHelper.kt | 58 +++++ .../Plugins/SftpPlugin/SftpPlugin.java | 3 +- .../Plugins/SftpPlugin/SimpleSftpServer.java | 38 --- .../UserInterface/SettingsFragment.java | 13 +- 8 files changed, 411 insertions(+), 49 deletions(-) create mode 100644 src/org/kde/kdeconnect/Backends/LanBackend/MdnsDiscovery.java create mode 100644 src/org/kde/kdeconnect/Backends/LanBackend/NsdResolveQueue.java create mode 100644 src/org/kde/kdeconnect/Helpers/NetworkHelper.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 3c0fbe1e..1eabcbf1 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -547,4 +547,6 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted Plugin stats + Enable UDP device discovery + diff --git a/src/org/kde/kdeconnect/Backends/LanBackend/LanLinkProvider.java b/src/org/kde/kdeconnect/Backends/LanBackend/LanLinkProvider.java index 569176f1..c72c0c22 100644 --- a/src/org/kde/kdeconnect/Backends/LanBackend/LanLinkProvider.java +++ b/src/org/kde/kdeconnect/Backends/LanBackend/LanLinkProvider.java @@ -25,6 +25,7 @@ import org.kde.kdeconnect.Helpers.TrustedNetworkHelper; import org.kde.kdeconnect.KdeConnect; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.UserInterface.CustomDevicesActivity; +import org.kde.kdeconnect.UserInterface.SettingsFragment; import java.io.BufferedReader; import java.io.IOException; @@ -58,9 +59,9 @@ import kotlin.text.Charsets; */ public class LanLinkProvider extends BaseLinkProvider { - private final static int UDP_PORT = 1716; - private final static int MIN_PORT = 1716; - private final static int MAX_PORT = 1764; + final static int UDP_PORT = 1716; + final static int MIN_PORT = 1716; + final static int MAX_PORT = 1764; final static int PAYLOAD_TRANSFER_MIN_PORT = 1739; final static int MAX_UDP_PACKET_SIZE = 1024 * 512; @@ -69,13 +70,15 @@ public class LanLinkProvider extends BaseLinkProvider { private final Context context; - private final HashMap visibleDevices = new HashMap<>(); //Links by device id + final HashMap visibleDevices = new HashMap<>(); //Links by device id final ConcurrentHashMap lastConnectionTime = new ConcurrentHashMap<>(); private ServerSocket tcpServer; private DatagramSocket udpServer; + private MdnsDiscovery mdnsDiscovery; + private long lastBroadcast = 0; private final static long delayBetweenBroadcasts = 200; @@ -265,6 +268,7 @@ public class LanLinkProvider extends BaseLinkProvider { public LanLinkProvider(Context context) { this.context = context; + this.mdnsDiscovery = new MdnsDiscovery(context, this); } private void setupUdpListener() { @@ -352,11 +356,11 @@ public class LanLinkProvider extends BaseLinkProvider { } private void broadcastUdpIdentityPacket() { - if (System.currentTimeMillis() < lastBroadcast + delayBetweenBroadcasts) { - Log.i("LanLinkProvider", "broadcastUdpPacket: relax cowboy"); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + if (!preferences.getBoolean(SettingsFragment.KEY_UDP_BROADCAST_ENABLED, true)) { + Log.i("LanLinkProvider", "UDP broadcast is disabled in settings. Skipping."); return; } - lastBroadcast = System.currentTimeMillis(); ThreadHelper.execute(() -> { List ipStringList = CustomDevicesActivity @@ -436,19 +440,32 @@ public class LanLinkProvider extends BaseLinkProvider { setupUdpListener(); setupTcpListener(); + mdnsDiscovery.startDiscovering(); + mdnsDiscovery.startAnnouncing(); + broadcastUdpIdentityPacket(); } } @Override public void onNetworkChange() { + if (System.currentTimeMillis() < lastBroadcast + delayBetweenBroadcasts) { + Log.i("LanLinkProvider", "onNetworkChange: relax cowboy"); + return; + } + lastBroadcast = System.currentTimeMillis(); + broadcastUdpIdentityPacket(); + mdnsDiscovery.stopDiscovering(); + mdnsDiscovery.startDiscovering(); } @Override public void onStop() { //Log.i("KDE/LanLinkProvider", "onStop"); listening = false; + mdnsDiscovery.stopAnnouncing(); + mdnsDiscovery.stopDiscovering(); try { tcpServer.close(); } catch (Exception e) { diff --git a/src/org/kde/kdeconnect/Backends/LanBackend/MdnsDiscovery.java b/src/org/kde/kdeconnect/Backends/LanBackend/MdnsDiscovery.java new file mode 100644 index 00000000..72c5ad5a --- /dev/null +++ b/src/org/kde/kdeconnect/Backends/LanBackend/MdnsDiscovery.java @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + + +package org.kde.kdeconnect.Backends.LanBackend; + +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.net.wifi.WifiManager; +import android.util.Log; + +import org.kde.kdeconnect.Helpers.DeviceHelper; + +import java.net.InetAddress; +import java.util.Collections; + +public class MdnsDiscovery { + + static final String LOG_TAG = "MdnsDiscovery"; + + static final String SERVICE_TYPE = "_kdeconnect._udp"; + + private final Context context; + + private final LanLinkProvider lanLinkProvider; + + private final NsdManager mNsdManager; + private NsdManager.RegistrationListener registrationListener; + private NsdManager.DiscoveryListener discoveryListener; + + private WifiManager.MulticastLock multicastLock; + + private NsdResolveQueue mNsdResolveQueue; + + public MdnsDiscovery(Context context, LanLinkProvider lanLinkProvider) { + this.context = context; + this.lanLinkProvider = lanLinkProvider; + this.mNsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE); + this.mNsdResolveQueue = new NsdResolveQueue(this.mNsdManager); + WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + multicastLock = wifiManager.createMulticastLock("kdeConnectMdnsMulticastLock"); + } + + void startDiscovering() { + if (discoveryListener == null) { + multicastLock.acquire(); + discoveryListener = createDiscoveryListener(); + mNsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener); + } + } + + void stopDiscovering() { + try { + if (discoveryListener != null) { + mNsdManager.stopServiceDiscovery(discoveryListener); + multicastLock.release(); + } + } catch(IllegalArgumentException e) { + // Ignore "listener not registered" exception + } + discoveryListener = null; + } + + void stopAnnouncing() { + try { + if (registrationListener != null) { + mNsdManager.unregisterService(registrationListener); + } + } catch(IllegalArgumentException e) { + // Ignore "listener not registered" exception + } + registrationListener = null; + } + + void startAnnouncing() { + if (registrationListener == null) { + NsdServiceInfo serviceInfo; + try { + serviceInfo = createNsdServiceInfo(); + } catch (IllegalAccessException e) { + Log.w(LOG_TAG, "Couldn't start announcing via MDNS: " + e.getMessage()); + return; + } + registrationListener = createRegistrationListener(); + mNsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener); + } + } + + NsdManager.RegistrationListener createRegistrationListener() { + return new NsdManager.RegistrationListener() { + + @Override + public void onServiceRegistered(NsdServiceInfo serviceInfo) { + // If Android changed the service name to avoid conflicts, here we can read it. + Log.i(LOG_TAG, "Registered " + serviceInfo.getServiceName()); + } + + @Override + public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) { + Log.e(LOG_TAG, "Registration failed with: " + errorCode); + } + + @Override + public void onServiceUnregistered(NsdServiceInfo serviceInfo) { + Log.d(LOG_TAG, "Service unregistered: " + serviceInfo); + } + + @Override + public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) { + Log.e(LOG_TAG, "Unregister of " + serviceInfo + " failed with: " + errorCode); + } + }; + } + + public NsdServiceInfo createNsdServiceInfo() throws IllegalAccessException { + NsdServiceInfo serviceInfo = new NsdServiceInfo(); + + String deviceId = DeviceHelper.getDeviceId(context); + // Without resolving the DNS, the service name is the only info we have so it must be sufficient to identify a device. + // Also, it must be unique, otherwise it will be automatically renamed. For these reasons we use the deviceId. + serviceInfo.setServiceName(deviceId); + serviceInfo.setServiceType(SERVICE_TYPE); + serviceInfo.setPort(LanLinkProvider.UDP_PORT); + + // The following fields aren't really used for anything, since we can't include enough info + // for it to be useful (namely: we can't include the device certificate). + // Each field (key + value) needs to be < 255 bytes. All the fields combined need to be < 1300 bytes. + // Also, on Android Lollipop those fields aren't resolved. + String deviceName = DeviceHelper.getDeviceName(context); + String deviceType = DeviceHelper.getDeviceType(context).toString(); + String protocolVersion = Integer.toString(DeviceHelper.ProtocolVersion); + serviceInfo.setAttribute("id", deviceId); + serviceInfo.setAttribute("name", deviceName); + serviceInfo.setAttribute("type", deviceType); + serviceInfo.setAttribute("protocol", protocolVersion); + + Log.i(LOG_TAG, "My MDNS info: " + serviceInfo); + + return serviceInfo; + } + + NsdManager.DiscoveryListener createDiscoveryListener() { + return new NsdManager.DiscoveryListener() { + + final String myId = DeviceHelper.getDeviceId(context); + + @Override + public void onDiscoveryStarted(String serviceType) { + Log.i(LOG_TAG, "Service discovery started: " + serviceType); + } + + @Override + public void onServiceFound(NsdServiceInfo serviceInfo) { + Log.d(LOG_TAG, "Service discovered: " + serviceInfo); + + String deviceId = serviceInfo.getServiceName(); + + if (myId.equals(deviceId)) { + Log.d(LOG_TAG, "Discovered myself, ignoring."); + return; + } + + if (lanLinkProvider.visibleDevices.containsKey(deviceId)) { + Log.i(LOG_TAG, "MDNS discovered " + deviceId + " to which I'm already connected to. Ignoring."); + return; + } + + // We use a queue because only one service can be resolved at + // a time, otherwise we get error 3 (already active) in onResolveFailed. + mNsdResolveQueue.resolveOrEnqueue(serviceInfo, createResolveListener()); + } + + @Override + public void onServiceLost(NsdServiceInfo serviceInfo) { + Log.w(LOG_TAG, "Service lost: " + serviceInfo); + // We can't see this device via mdns. This probably means it's not reachable anymore + // but we do nothing here since we have other ways to do detect unreachable devices + // that hopefully will also trigger. + } + + @Override + public void onDiscoveryStopped(String serviceType) { + Log.i(LOG_TAG, "MDNS discovery stopped: " + serviceType); + } + + @Override + public void onStartDiscoveryFailed(String serviceType, int errorCode) { + Log.e(LOG_TAG, "MDNS discovery start failed: " + errorCode); + } + + @Override + public void onStopDiscoveryFailed(String serviceType, int errorCode) { + Log.e(LOG_TAG, "MDNS discovery stop failed: " + errorCode); + } + }; + } + + + /** + * Returns a new listener instance since NsdManager wants a different listener each time you call resolveService + */ + NsdManager.ResolveListener createResolveListener() { + return new NsdManager.ResolveListener() { + @Override + public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) { + Log.w(LOG_TAG, "MDNS error " + errorCode + " resolving service: " + serviceInfo); + } + + @Override + public void onServiceResolved(NsdServiceInfo serviceInfo) { + Log.i(LOG_TAG, "MDNS successfully resolved " + serviceInfo); + + // Let the LanLinkProvider handle the connection + InetAddress remoteAddress = serviceInfo.getHost(); + lanLinkProvider.sendUdpIdentityPacket(Collections.singletonList(remoteAddress)); + } + }; + } + +} diff --git a/src/org/kde/kdeconnect/Backends/LanBackend/NsdResolveQueue.java b/src/org/kde/kdeconnect/Backends/LanBackend/NsdResolveQueue.java new file mode 100644 index 00000000..c61506af --- /dev/null +++ b/src/org/kde/kdeconnect/Backends/LanBackend/NsdResolveQueue.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Backends.LanBackend; + +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.LinkedList; + +public class NsdResolveQueue { + + static final String LOG_TAG = "NsdResolveQueue"; + + final @NonNull NsdManager mNsdManager; + + private final Object mLock = new Object(); + private final LinkedList mResolveRequests = new LinkedList<>(); + + public NsdResolveQueue(NsdManager nsdManager) { + this.mNsdManager = nsdManager; + } + + private static class PendingResolve { + final @NonNull NsdServiceInfo serviceInfo; + final @NonNull NsdManager.ResolveListener listener; + + private PendingResolve(@NonNull NsdServiceInfo serviceInfo, @NonNull NsdManager.ResolveListener listener) { + this.serviceInfo = serviceInfo; + this.listener = listener; + } + } + + public void resolveOrEnqueue(@NonNull NsdServiceInfo serviceInfo, @NonNull NsdManager.ResolveListener listener) { + synchronized (mLock) { + for (PendingResolve existing : mResolveRequests) { + if (serviceInfo.getServiceName().equals(existing.serviceInfo.getServiceName())) { + Log.i(LOG_TAG, "Not enqueuing a new resolve request for the same service: " + serviceInfo.getServiceName()); + return; + } + } + mResolveRequests.addLast(new PendingResolve(serviceInfo, new ListenerWrapper(listener))); + + if (mResolveRequests.size() == 1) { + resolveNextRequest(); + } + } + } + + private class ListenerWrapper implements NsdManager.ResolveListener { + private final @NonNull NsdManager.ResolveListener mListener; + + private ListenerWrapper(@NonNull NsdManager.ResolveListener listener) { + mListener = listener; + } + + @Override + public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) { + mListener.onResolveFailed(serviceInfo, errorCode); + + synchronized (mLock) { + mResolveRequests.pop(); + resolveNextRequest(); + } + } + + @Override + public void onServiceResolved(NsdServiceInfo serviceInfo) { + mListener.onServiceResolved(serviceInfo); + + synchronized (mLock) { + mResolveRequests.pop(); + resolveNextRequest(); + } + } + } + + private void resolveNextRequest() { + if (!mResolveRequests.isEmpty()) { + PendingResolve request = mResolveRequests.getFirst(); + mNsdManager.resolveService(request.serviceInfo, request.listener); + } + } + +} diff --git a/src/org/kde/kdeconnect/Helpers/NetworkHelper.kt b/src/org/kde/kdeconnect/Helpers/NetworkHelper.kt new file mode 100644 index 00000000..d7af9a79 --- /dev/null +++ b/src/org/kde/kdeconnect/Helpers/NetworkHelper.kt @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ +package org.kde.kdeconnect.Helpers + +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.SocketException + +object NetworkHelper { + //Prefer IPv4 over IPv6, because sshfs doesn't seem to like IPv6 + // Anything with rmnet is related to cellular connections or USB + // tethering mechanisms. See: + // + // https://android.googlesource.com/kernel/msm/+/android-msm-flo-3.4-kitkat-mr1/Documentation/usb/gadget_rmnet.txt + // + // 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. + @JvmStatic + val localIpAddress: InetAddress? + get() { + var ip6: InetAddress? = null + try { + for (intf in NetworkInterface.getNetworkInterfaces()) { + + // Anything with rmnet is related to cellular connections or USB + // tethering mechanisms. See: + // + // https://android.googlesource.com/kernel/msm/+/android-msm-flo-3.4-kitkat-mr1/Documentation/usb/gadget_rmnet.txt + // + // 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. + if (intf.displayName.contains("rmnet")) { + continue + } + val enumIpAddr = intf.inetAddresses + while (enumIpAddr.hasMoreElements()) { + val inetAddress = enumIpAddr.nextElement() + if (!inetAddress.isLoopbackAddress) { + ip6 = + if (inetAddress is Inet4Address) { //Prefer IPv4 over IPv6, because sshfs doesn't seem to like IPv6 + return inetAddress + } else { + inetAddress + } + } + } + } + } catch (ignored: SocketException) { + } + return ip6 + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java index 7d33db92..4d3ac496 100644 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java @@ -20,6 +20,7 @@ import androidx.annotation.NonNull; import org.json.JSONException; import org.json.JSONObject; +import org.kde.kdeconnect.Helpers.NetworkHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; @@ -147,7 +148,7 @@ public class SftpPlugin extends Plugin implements SharedPreferences.OnSharedPref NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP); - np2.set("ip", server.getLocalIpAddress()); // for backwards compatibility + np2.set("ip", NetworkHelper.getLocalIpAddress().getHostAddress()); np2.set("port", server.getPort()); np2.set("user", SimpleSftpServer.USER); np2.set("password", server.getPassword()); diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java index 80d023b1..caa452e5 100644 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java @@ -26,17 +26,12 @@ import org.kde.kdeconnect.Helpers.RandomHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper; import java.io.IOException; -import java.net.Inet4Address; -import java.net.InetAddress; -import java.net.NetworkInterface; -import java.net.SocketException; import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Arrays; import java.util.Collections; -import java.util.Enumeration; import java.util.List; class SimpleSftpServer { @@ -145,39 +140,6 @@ class SimpleSftpServer { return port; } - String getLocalIpAddress() { - String ip6 = null; - try { - for (Enumeration en = NetworkInterface.getNetworkInterfaces(); en.hasMoreElements(); ) { - NetworkInterface intf = en.nextElement(); - - // Anything with rmnet is related to cellular connections or USB - // tethering mechanisms. See: - // - // https://android.googlesource.com/kernel/msm/+/android-msm-flo-3.4-kitkat-mr1/Documentation/usb/gadget_rmnet.txt - // - // 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. - if (intf.getDisplayName().contains("rmnet")) continue; - - for (Enumeration enumIpAddr = intf.getInetAddresses(); enumIpAddr.hasMoreElements(); ) { - InetAddress inetAddress = enumIpAddr.nextElement(); - if (!inetAddress.isLoopbackAddress()) { - String address = inetAddress.getHostAddress(); - if (inetAddress instanceof Inet4Address) { //Prefer IPv4 over IPv6, because sshfs doesn't seem to like IPv6 - return address; - } else { - ip6 = address; - } - } - } - } - } catch (SocketException ignored) { - } - return ip6; - } - public boolean isInitialized() { return initialized; } diff --git a/src/org/kde/kdeconnect/UserInterface/SettingsFragment.java b/src/org/kde/kdeconnect/UserInterface/SettingsFragment.java index e3e8fd1b..fef0a600 100644 --- a/src/org/kde/kdeconnect/UserInterface/SettingsFragment.java +++ b/src/org/kde/kdeconnect/UserInterface/SettingsFragment.java @@ -37,6 +37,9 @@ import org.kde.kdeconnect_tp.R; public class SettingsFragment extends PreferenceFragmentCompat { + public static final String KEY_UDP_BROADCAST_ENABLED = "udp_broadcast_enabled"; + public static final String KEY_APP_THEME = "theme_pref"; + private EditTextPreference renameDevice; @NonNull @@ -90,7 +93,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { // Theme Selector ListPreference themeSelector = new ListPreference(context); - themeSelector.setKey("theme_pref"); + themeSelector.setKey(KEY_APP_THEME); themeSelector.setTitle(R.string.theme_dialog_title); themeSelector.setDialogTitle(R.string.theme_dialog_title); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -168,6 +171,12 @@ public class SettingsFragment extends PreferenceFragmentCompat { return true; }); + // UDP broadcast toggle + final TwoStatePreference udpBroadcastDiscovery = new SwitchPreference(context); + udpBroadcastDiscovery.setDefaultValue(true); + udpBroadcastDiscovery.setKey(KEY_UDP_BROADCAST_ENABLED); + udpBroadcastDiscovery.setTitle(R.string.enable_udp_broadcast); + screen.addPreference(udpBroadcastDiscovery); // More settings text Preference moreSettingsText = new Preference(context); @@ -178,7 +187,5 @@ public class SettingsFragment extends PreferenceFragmentCompat { screen.addPreference(moreSettingsText); setPreferenceScreen(screen); - - } }