diff --git a/service/Android.bp b/service/Android.bp index 45e43bc291..c2dbce1513 100644 --- a/service/Android.bp +++ b/service/Android.bp @@ -181,6 +181,25 @@ java_library { ], } +// TODO: Remove this temporary library and put code into module when test coverage is enough. +java_library { + name: "service-mdns", + sdk_version: "system_server_current", + min_sdk_version: "30", + srcs: [ + "mdns/**/*.java", + ], + libs: [ + "framework-annotations-lib", + "framework-connectivity-pre-jarjar", + "framework-wifi.stubs.module_lib", + "service-connectivity-pre-jarjar", + ], + visibility: [ + "//packages/modules/Connectivity/tests:__subpackages__", + ], +} + java_library { name: "service-connectivity-protos", sdk_version: "system_current", diff --git a/service/mdns/com/android/server/connectivity/mdns/ConnectivityMonitor.java b/service/mdns/com/android/server/connectivity/mdns/ConnectivityMonitor.java new file mode 100644 index 0000000000..2b99d0a924 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/ConnectivityMonitor.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +/** Interface for monitoring connectivity changes. */ +public interface ConnectivityMonitor { + /** + * Starts monitoring changes of connectivity of this device, which may indicate that the list of + * network interfaces available for multi-cast messaging has changed. + */ + void startWatchingConnectivityChanges(); + + /** Stops monitoring changes of connectivity. */ + void stopWatchingConnectivityChanges(); + + void notifyConnectivityChange(); + + /** Listener interface for receiving connectivity changes. */ + interface Listener { + void onConnectivityChanged(); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/ConnectivityMonitorWithConnectivityManager.java b/service/mdns/com/android/server/connectivity/mdns/ConnectivityMonitorWithConnectivityManager.java new file mode 100644 index 0000000000..3563d6118c --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/ConnectivityMonitorWithConnectivityManager.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.TargetApi; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.os.Build; + +import com.android.server.connectivity.mdns.util.MdnsLogger; + +/** Class for monitoring connectivity changes using {@link ConnectivityManager}. */ +public class ConnectivityMonitorWithConnectivityManager implements ConnectivityMonitor { + private static final String TAG = "ConnMntrWConnMgr"; + private static final MdnsLogger LOGGER = new MdnsLogger(TAG); + + private final Listener listener; + private final ConnectivityManager.NetworkCallback networkCallback; + private final ConnectivityManager connectivityManager; + // TODO(b/71901993): Ideally we shouldn't need this flag. However we still don't have clues why + // the receiver is unregistered twice yet. + private boolean isCallbackRegistered = false; + + @SuppressWarnings({"nullness:assignment", "nullness:method.invocation"}) + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public ConnectivityMonitorWithConnectivityManager(Context context, Listener listener) { + this.listener = listener; + + connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + networkCallback = + new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + LOGGER.log("network available."); + notifyConnectivityChange(); + } + + @Override + public void onLost(Network network) { + LOGGER.log("network lost."); + notifyConnectivityChange(); + } + + @Override + public void onUnavailable() { + LOGGER.log("network unavailable."); + notifyConnectivityChange(); + } + }; + } + + @Override + public void notifyConnectivityChange() { + listener.onConnectivityChanged(); + } + + /** + * Starts monitoring changes of connectivity of this device, which may indicate that the list of + * network interfaces available for multi-cast messaging has changed. + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void startWatchingConnectivityChanges() { + LOGGER.log("Start watching connectivity changes"); + if (isCallbackRegistered) { + return; + } + + connectivityManager.registerNetworkCallback( + new NetworkRequest.Builder().addTransportType( + NetworkCapabilities.TRANSPORT_WIFI).build(), + networkCallback); + isCallbackRegistered = true; + } + + /** Stops monitoring changes of connectivity. */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void stopWatchingConnectivityChanges() { + LOGGER.log("Stop watching connectivity changes"); + if (!isCallbackRegistered) { + return; + } + + connectivityManager.unregisterNetworkCallback(networkCallback); + isCallbackRegistered = false; + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java b/service/mdns/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java new file mode 100644 index 0000000000..3db1b228f4 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; +import android.text.TextUtils; +import android.util.Pair; + +import com.android.server.connectivity.mdns.util.MdnsLogger; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.net.DatagramPacket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * A {@link Callable} that builds and enqueues a mDNS query to send over the multicast socket. If a + * query is built and enqueued successfully, then call to {@link #call()} returns the transaction ID + * and the list of the subtypes in the query as a {@link Pair}. If a query is failed to build, or if + * it can not be enqueued, then call to {@link #call()} returns {@code null}. + */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +public class EnqueueMdnsQueryCallable implements Callable>> { + + private static final String TAG = "MdnsQueryCallable"; + private static final MdnsLogger LOGGER = new MdnsLogger(TAG); + private static final List castShellEmulatorMdnsPorts; + + static { + castShellEmulatorMdnsPorts = new ArrayList<>(); + String[] stringPorts = MdnsConfigs.castShellEmulatorMdnsPorts(); + + for (String port : stringPorts) { + try { + castShellEmulatorMdnsPorts.add(Integer.parseInt(port)); + } catch (NumberFormatException e) { + // Ignore. + } + } + } + + private final WeakReference weakRequestSender; + private final MdnsPacketWriter packetWriter; + private final String[] serviceTypeLabels; + private final List subtypes; + private final boolean expectUnicastResponse; + private final int transactionId; + + EnqueueMdnsQueryCallable( + @NonNull MdnsSocketClient requestSender, + @NonNull MdnsPacketWriter packetWriter, + @NonNull String serviceType, + @NonNull Collection subtypes, + boolean expectUnicastResponse, + int transactionId) { + weakRequestSender = new WeakReference<>(requestSender); + this.packetWriter = packetWriter; + serviceTypeLabels = TextUtils.split(serviceType, "\\."); + this.subtypes = new ArrayList<>(subtypes); + this.expectUnicastResponse = expectUnicastResponse; + this.transactionId = transactionId; + } + + @Override + public Pair> call() { + try { + MdnsSocketClient requestSender = weakRequestSender.get(); + if (requestSender == null) { + return null; + } + + int numQuestions = 1; + if (!subtypes.isEmpty()) { + numQuestions += subtypes.size(); + } + + // Header. + packetWriter.writeUInt16(transactionId); // transaction ID + packetWriter.writeUInt16(MdnsConstants.FLAGS_QUERY); // flags + packetWriter.writeUInt16(numQuestions); // number of questions + packetWriter.writeUInt16(0); // number of answers (not yet known; will be written later) + packetWriter.writeUInt16(0); // number of authority entries + packetWriter.writeUInt16(0); // number of additional records + + // Question(s). There will be one question for each (fqdn+subtype, recordType) + // combination, + // as well as one for each (fqdn, recordType) combination. + + for (String subtype : subtypes) { + String[] labels = new String[serviceTypeLabels.length + 2]; + labels[0] = MdnsConstants.SUBTYPE_PREFIX + subtype; + labels[1] = MdnsConstants.SUBTYPE_LABEL; + System.arraycopy(serviceTypeLabels, 0, labels, 2, serviceTypeLabels.length); + + packetWriter.writeLabels(labels); + packetWriter.writeUInt16(MdnsRecord.TYPE_PTR); + packetWriter.writeUInt16( + MdnsConstants.QCLASS_INTERNET + | (expectUnicastResponse ? MdnsConstants.QCLASS_UNICAST : 0)); + } + + packetWriter.writeLabels(serviceTypeLabels); + packetWriter.writeUInt16(MdnsRecord.TYPE_PTR); + packetWriter.writeUInt16( + MdnsConstants.QCLASS_INTERNET + | (expectUnicastResponse ? MdnsConstants.QCLASS_UNICAST : 0)); + + InetAddress mdnsAddress = MdnsConstants.getMdnsIPv4Address(); + if (requestSender.isOnIPv6OnlyNetwork()) { + mdnsAddress = MdnsConstants.getMdnsIPv6Address(); + } + + sendPacketTo(requestSender, + new InetSocketAddress(mdnsAddress, MdnsConstants.MDNS_PORT)); + for (Integer emulatorPort : castShellEmulatorMdnsPorts) { + sendPacketTo(requestSender, new InetSocketAddress(mdnsAddress, emulatorPort)); + } + return Pair.create(transactionId, subtypes); + } catch (IOException e) { + LOGGER.e(String.format("Failed to create mDNS packet for subtype: %s.", + TextUtils.join(",", subtypes)), e); + return null; + } + } + + private void sendPacketTo(MdnsSocketClient requestSender, InetSocketAddress address) + throws IOException { + DatagramPacket packet = packetWriter.getPacket(address); + if (expectUnicastResponse) { + requestSender.sendUnicastPacket(packet); + } else { + requestSender.sendMulticastPacket(packet); + } + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/ExecutorProvider.java b/service/mdns/com/android/server/connectivity/mdns/ExecutorProvider.java new file mode 100644 index 0000000000..72b65e095b --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/ExecutorProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.util.ArraySet; + +import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +/** + * This class provides {@link ScheduledExecutorService} instances to {@link MdnsServiceTypeClient} + * instances, and provides method to shutdown all the created executors. + */ +public class ExecutorProvider { + + private final Set serviceTypeClientSchedulerExecutors = + new ArraySet<>(); + + /** Returns a new {@link ScheduledExecutorService} instance. */ + public ScheduledExecutorService newServiceTypeClientSchedulerExecutor() { + // TODO: actually use a pool ? + ScheduledExecutorService executor = new ScheduledThreadPoolExecutor(1); + serviceTypeClientSchedulerExecutors.add(executor); + return executor; + } + + /** Shuts down all the created {@link ScheduledExecutorService} instances. */ + public void shutdownAll() { + for (ScheduledExecutorService executor : serviceTypeClientSchedulerExecutors) { + executor.shutdownNow(); + } + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsConfigs.java b/service/mdns/com/android/server/connectivity/mdns/MdnsConfigs.java new file mode 100644 index 0000000000..922037bca9 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsConfigs.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +/** + * mDNS configuration values. + * + * TODO: consider making some of these adjustable via flags. + */ +public class MdnsConfigs { + public static String[] castShellEmulatorMdnsPorts() { + return new String[0]; + } + + public static long initialTimeBetweenBurstsMs() { + return 5000L; + } + + public static long timeBetweenBurstsMs() { + return 20_000L; + } + + public static int queriesPerBurst() { + return 3; + } + + public static long timeBetweenQueriesInBurstMs() { + return 1000L; + } + + public static int queriesPerBurstPassive() { + return 1; + } + + public static boolean alwaysAskForUnicastResponseInEachBurst() { + return false; + } + + public static boolean useSessionIdToScheduleMdnsTask() { + return false; + } + + public static boolean shouldCancelScanTaskWhenFutureIsNull() { + return false; + } + + public static long sleepTimeForSocketThreadMs() { + return 20_000L; + } + + public static boolean checkMulticastResponse() { + return false; + } + + public static boolean useSeparateSocketToSendUnicastQuery() { + return false; + } + + public static long checkMulticastResponseIntervalMs() { + return 10_000L; + } + + public static boolean clearMdnsPacketQueueAfterDiscoveryStops() { + return true; + } + + public static boolean allowAddMdnsPacketAfterDiscoveryStops() { + return false; + } + + public static int mdnsPacketQueueMaxSize() { + return Integer.MAX_VALUE; + } + + public static boolean preferIpv6() { + return false; + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsConstants.java b/service/mdns/com/android/server/connectivity/mdns/MdnsConstants.java new file mode 100644 index 0000000000..ed28700c9c --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsConstants.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.TargetApi; +import android.os.Build; + +import com.android.internal.annotations.VisibleForTesting; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** mDNS-related constants. */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +@VisibleForTesting +public final class MdnsConstants { + public static final int MDNS_PORT = 5353; + // Flags word format is: + // 15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00 + // QR [ Opcode ] AA TC RD RA Z AD CD [ Rcode ] + // See http://www.networksorcery.com/enp/protocol/dns.htm + // For responses, QR bit should be 1, AA - CD bits should be ignored, and all other bits + // should be 0. + public static final int FLAGS_QUERY = 0x0000; + public static final int FLAGS_RESPONSE_MASK = 0xF80F; + public static final int FLAGS_RESPONSE = 0x8000; + public static final int QCLASS_INTERNET = 0x0001; + public static final int QCLASS_UNICAST = 0x8000; + public static final String SUBTYPE_LABEL = "_sub"; + public static final String SUBTYPE_PREFIX = "_"; + private static final String MDNS_IPV4_HOST_ADDRESS = "224.0.0.251"; + private static final String MDNS_IPV6_HOST_ADDRESS = "FF02::FB"; + private static InetAddress mdnsAddress; + private static Charset utf8Charset; + private MdnsConstants() { + } + + public static InetAddress getMdnsIPv4Address() { + synchronized (MdnsConstants.class) { + InetAddress addr = null; + try { + addr = InetAddress.getByName(MDNS_IPV4_HOST_ADDRESS); + } catch (UnknownHostException e) { + /* won't happen */ + } + mdnsAddress = addr; + return mdnsAddress; + } + } + + public static InetAddress getMdnsIPv6Address() { + synchronized (MdnsConstants.class) { + InetAddress addr = null; + try { + addr = InetAddress.getByName(MDNS_IPV6_HOST_ADDRESS); + } catch (UnknownHostException e) { + /* won't happen */ + } + mdnsAddress = addr; + return mdnsAddress; + } + } + + public static Charset getUtf8Charset() { + synchronized (MdnsConstants.class) { + if (utf8Charset == null) { + utf8Charset = getUtf8CharsetOnKitKat(); + } + return utf8Charset; + } + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + private static Charset getUtf8CharsetOnKitKat() { + return StandardCharsets.UTF_8; + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java b/service/mdns/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java new file mode 100644 index 0000000000..1faa6ceefd --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.Manifest.permission; +import android.annotation.NonNull; +import android.annotation.RequiresPermission; +import android.text.TextUtils; +import android.util.ArrayMap; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.connectivity.mdns.util.MdnsLogger; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; + +/** + * This class keeps tracking the set of registered {@link MdnsServiceBrowserListener} instances, and + * notify them when a mDNS service instance is found, updated, or removed? + */ +public class MdnsDiscoveryManager implements MdnsSocketClient.Callback { + + private static final MdnsLogger LOGGER = new MdnsLogger("MdnsDiscoveryManager"); + + private final ExecutorProvider executorProvider; + private final MdnsSocketClient socketClient; + + private final Map serviceTypeClients = new ArrayMap<>(); + + public MdnsDiscoveryManager( + @NonNull ExecutorProvider executorProvider, @NonNull MdnsSocketClient socketClient) { + this.executorProvider = executorProvider; + this.socketClient = socketClient; + } + + /** + * Starts (or continue) to discovery mDNS services with given {@code serviceType}, and registers + * {@code listener} for receiving mDNS service discovery responses. + * + * @param serviceType The type of the service to discover. + * @param listener The {@link MdnsServiceBrowserListener} listener. + * @param searchOptions The {@link MdnsSearchOptions} to be used for discovering {@code + * serviceType}. + */ + @RequiresPermission(permission.CHANGE_WIFI_MULTICAST_STATE) + public synchronized void registerListener( + @NonNull String serviceType, + @NonNull MdnsServiceBrowserListener listener, + @NonNull MdnsSearchOptions searchOptions) { + LOGGER.log( + "Registering listener for subtypes: %s", + TextUtils.join(",", searchOptions.getSubtypes())); + if (serviceTypeClients.isEmpty()) { + // First listener. Starts the socket client. + try { + socketClient.startDiscovery(); + } catch (IOException e) { + LOGGER.e("Failed to start discover.", e); + return; + } + } + // All listeners of the same service types shares the same MdnsServiceTypeClient. + MdnsServiceTypeClient serviceTypeClient = serviceTypeClients.get(serviceType); + if (serviceTypeClient == null) { + serviceTypeClient = createServiceTypeClient(serviceType); + serviceTypeClients.put(serviceType, serviceTypeClient); + } + serviceTypeClient.startSendAndReceive(listener, searchOptions); + } + + /** + * Unregister {@code listener} for receiving mDNS service discovery responses. IF no listener is + * registered for the given service type, stops discovery for the service type. + * + * @param serviceType The type of the service to discover. + * @param listener The {@link MdnsServiceBrowserListener} listener. + */ + @RequiresPermission(permission.CHANGE_WIFI_MULTICAST_STATE) + public synchronized void unregisterListener( + @NonNull String serviceType, @NonNull MdnsServiceBrowserListener listener) { + LOGGER.log("Unregistering listener for service type: %s", serviceType); + MdnsServiceTypeClient serviceTypeClient = serviceTypeClients.get(serviceType); + if (serviceTypeClient == null) { + return; + } + if (serviceTypeClient.stopSendAndReceive(listener)) { + // No listener is registered for the service type anymore, remove it from the list of + // the + // service type clients. + serviceTypeClients.remove(serviceType); + if (serviceTypeClients.isEmpty()) { + // No discovery request. Stops the socket client. + socketClient.stopDiscovery(); + } + } + } + + @Override + public synchronized void onResponseReceived(@NonNull MdnsResponse response) { + String[] name = + response.getPointerRecords().isEmpty() + ? null + : response.getPointerRecords().get(0).getName(); + if (name != null) { + for (MdnsServiceTypeClient serviceTypeClient : serviceTypeClients.values()) { + String[] serviceType = serviceTypeClient.getServiceTypeLabels(); + if ((Arrays.equals(name, serviceType) + || ((name.length == (serviceType.length + 2)) + && name[1].equals(MdnsConstants.SUBTYPE_LABEL) + && MdnsRecord.labelsAreSuffix(serviceType, name)))) { + serviceTypeClient.processResponse(response); + return; + } + } + } + } + + @Override + public synchronized void onFailedToParseMdnsResponse(int receivedPacketNumber, int errorCode) { + for (MdnsServiceTypeClient serviceTypeClient : serviceTypeClients.values()) { + serviceTypeClient.onFailedToParseMdnsResponse(receivedPacketNumber, errorCode); + } + } + + @VisibleForTesting + MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType) { + return new MdnsServiceTypeClient( + serviceType, socketClient, + executorProvider.newServiceTypeClientSchedulerExecutor()); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java b/service/mdns/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java new file mode 100644 index 0000000000..e35743c8f6 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Locale; +import java.util.Objects; + +/** An mDNS "AAAA" or "A" record, which holds an IPv6 or IPv4 address. */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +@VisibleForTesting +public class MdnsInetAddressRecord extends MdnsRecord { + private Inet6Address inet6Address; + private Inet4Address inet4Address; + + /** + * Constructs the {@link MdnsRecord} + * + * @param name the service host name + * @param type the type of record (either Type 'AAAA' or Type 'A') + * @param reader the reader to read the record from. + */ + public MdnsInetAddressRecord(String[] name, int type, MdnsPacketReader reader) + throws IOException { + super(name, type, reader); + } + + /** Returns the IPv6 address. */ + public Inet6Address getInet6Address() { + return inet6Address; + } + + /** Returns the IPv4 address. */ + public Inet4Address getInet4Address() { + return inet4Address; + } + + @Override + protected void readData(MdnsPacketReader reader) throws IOException { + int size = 4; + if (super.getType() == MdnsRecord.TYPE_AAAA) { + size = 16; + } + byte[] buf = new byte[size]; + reader.readBytes(buf); + try { + InetAddress address = InetAddress.getByAddress(buf); + if (address instanceof Inet4Address) { + inet4Address = (Inet4Address) address; + inet6Address = null; + } else if (address instanceof Inet6Address) { + inet4Address = null; + inet6Address = (Inet6Address) address; + } else { + inet4Address = null; + inet6Address = null; + } + } catch (UnknownHostException e) { + // Ignore exception + } + } + + @Override + protected void writeData(MdnsPacketWriter writer) throws IOException { + byte[] buf = null; + if (inet4Address != null) { + buf = inet4Address.getAddress(); + } else if (inet6Address != null) { + buf = inet6Address.getAddress(); + } + if (buf != null) { + writer.writeBytes(buf); + } + } + + @Override + public String toString() { + String type = "AAAA"; + if (super.getType() == MdnsRecord.TYPE_A) { + type = "A"; + } + return String.format( + Locale.ROOT, "%s: Inet4Address: %s Inet6Address: %s", type, inet4Address, + inet6Address); + } + + @Override + public int hashCode() { + return (super.hashCode() * 31) + + Objects.hashCode(inet4Address) + + Objects.hashCode(inet6Address); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof MdnsInetAddressRecord)) { + return false; + } + + return super.equals(other) + && Objects.equals(inet4Address, ((MdnsInetAddressRecord) other).inet4Address) + && Objects.equals(inet6Address, ((MdnsInetAddressRecord) other).inet6Address); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsPacketReader.java b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketReader.java new file mode 100644 index 0000000000..61c5f5a6bc --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketReader.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.util.SparseArray; + +import java.io.EOFException; +import java.io.IOException; +import java.net.DatagramPacket; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** Simple decoder for mDNS packets. */ +public class MdnsPacketReader { + private final byte[] buf; + private final int count; + private final SparseArray labelDictionary; + private int pos; + private int limit; + + /** Constructs a reader for the given packet. */ + public MdnsPacketReader(DatagramPacket packet) { + buf = packet.getData(); + count = packet.getLength(); + pos = 0; + limit = -1; + labelDictionary = new SparseArray<>(16); + } + + /** + * Sets a temporary limit (from the current read position) for subsequent reads. Any attempt to + * read past this limit will result in an EOFException. + * + * @param limit The new limit. + * @throws IOException If there is insufficient data for the new limit. + */ + public void setLimit(int limit) throws IOException { + if (limit >= 0) { + if (pos + limit <= count) { + this.limit = pos + limit; + } else { + throw new IOException( + String.format( + Locale.ROOT, + "attempt to set limit beyond available data: %d exceeds %d", + pos + limit, + count)); + } + } + } + + /** Clears the limit set by {@link #setLimit}. */ + public void clearLimit() { + limit = -1; + } + + /** + * Returns the number of bytes left to read, between the current read position and either the + * limit (if set) or the end of the packet. + */ + public int getRemaining() { + return (limit >= 0 ? limit : count) - pos; + } + + /** + * Reads an unsigned 8-bit integer. + * + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + */ + public int readUInt8() throws EOFException { + checkRemaining(1); + byte val = buf[pos++]; + return val & 0xFF; + } + + /** + * Reads an unsigned 16-bit integer. + * + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + */ + public int readUInt16() throws EOFException { + checkRemaining(2); + int val = (buf[pos++] & 0xFF) << 8; + val |= (buf[pos++]) & 0xFF; + return val; + } + + /** + * Reads an unsigned 32-bit integer. + * + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + */ + public long readUInt32() throws EOFException { + checkRemaining(4); + long val = (long) (buf[pos++] & 0xFF) << 24; + val |= (long) (buf[pos++] & 0xFF) << 16; + val |= (long) (buf[pos++] & 0xFF) << 8; + val |= buf[pos++] & 0xFF; + return val; + } + + /** + * Reads a sequence of labels and returns them as an array of strings. A sequence of labels is + * either a sequence of strings terminated by a NUL byte, a sequence of strings terminated by a + * pointer, or a pointer. + * + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + * @throws IOException If invalid data is read. + */ + public String[] readLabels() throws IOException { + List result = new ArrayList<>(5); + LabelEntry previousEntry = null; + + while (getRemaining() > 0) { + byte nextByte = peekByte(); + + if (nextByte == 0) { + // A NUL byte terminates a sequence of labels. + skip(1); + break; + } + + int currentOffset = pos; + + boolean isLabelPointer = (nextByte & 0xC0) == 0xC0; + if (isLabelPointer) { + // A pointer terminates a sequence of labels. Store the pointer value in the + // previous label entry. + int labelOffset = ((readUInt8() & 0x3F) << 8) | (readUInt8() & 0xFF); + if (previousEntry != null) { + previousEntry.nextOffset = labelOffset; + } + + // Follow the chain of labels starting at this pointer, adding all of them onto the + // result. + while (labelOffset != 0) { + LabelEntry entry = labelDictionary.get(labelOffset); + if (entry == null) { + throw new IOException( + String.format(Locale.ROOT, "Invalid label pointer: %04X", + labelOffset)); + } + result.add(entry.label); + labelOffset = entry.nextOffset; + } + break; + } else { + // It's an ordinary label. Chain it onto the previous label entry (if any), and add + // it onto the result. + String val = readString(); + LabelEntry newEntry = new LabelEntry(val); + labelDictionary.put(currentOffset, newEntry); + + if (previousEntry != null) { + previousEntry.nextOffset = currentOffset; + } + previousEntry = newEntry; + result.add(val); + } + } + + return result.toArray(new String[result.size()]); + } + + /** + * Reads a length-prefixed string. + * + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + */ + public String readString() throws EOFException { + int len = readUInt8(); + checkRemaining(len); + String val = new String(buf, pos, len, MdnsConstants.getUtf8Charset()); + pos += len; + return val; + } + + /** + * Reads a specific number of bytes. + * + * @param bytes The array to fill. + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + */ + public void readBytes(byte[] bytes) throws EOFException { + checkRemaining(bytes.length); + System.arraycopy(buf, pos, bytes, 0, bytes.length); + pos += bytes.length; + } + + /** + * Skips over the given number of bytes. + * + * @param count The number of bytes to read and discard. + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + */ + public void skip(int count) throws EOFException { + checkRemaining(count); + pos += count; + } + + /** + * Peeks at and returns the next byte in the packet, without advancing the read position. + * + * @throws EOFException If there are not enough bytes remaining in the packet to satisfy the + * read. + */ + public byte peekByte() throws EOFException { + checkRemaining(1); + return buf[pos]; + } + + /** Returns the current byte position of the reader for the data packet. */ + public int getPosition() { + return pos; + } + + // Checks if the number of remaining bytes to be read in the packet is at least |count|. + private void checkRemaining(int count) throws EOFException { + if (getRemaining() < count) { + throw new EOFException(); + } + } + + private static class LabelEntry { + public final String label; + public int nextOffset = 0; + + public LabelEntry(String label) { + this.label = label; + } + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsPacketWriter.java b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketWriter.java new file mode 100644 index 0000000000..2fed36df94 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketWriter.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.SocketAddress; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** Simple encoder for mDNS packets. */ +public class MdnsPacketWriter { + private static final int MDNS_POINTER_MASK = 0xC000; + private final byte[] data; + private final Map labelDictionary; + private int pos = 0; + private int savedWritePos = -1; + + /** + * Constructs a writer for a new packet. + * + * @param maxSize The maximum size of a packet. + */ + public MdnsPacketWriter(int maxSize) { + if (maxSize <= 0) { + throw new IllegalArgumentException("invalid size"); + } + + data = new byte[maxSize]; + labelDictionary = new HashMap<>(); + } + + /** Returns the current write position. */ + public int getWritePosition() { + return pos; + } + + /** + * Saves the current write position and then rewinds the write position by the given number of + * bytes. This is useful for updating length fields earlier in the packet. Rewinds cannot be + * nested. + * + * @param position The position to rewind to. + * @throws IOException If the count would go beyond the beginning of the packet, or if there is + * already a rewind in effect. + */ + public void rewind(int position) throws IOException { + if ((savedWritePos != -1) || (position > pos) || (position < 0)) { + throw new IOException("invalid rewind"); + } + + savedWritePos = pos; + pos = position; + } + + /** + * Sets the current write position to what it was prior to the last rewind. + * + * @throws IOException If there was no rewind in effect. + */ + public void unrewind() throws IOException { + if (savedWritePos == -1) { + throw new IOException("no rewind is in effect"); + } + pos = savedWritePos; + savedWritePos = -1; + } + + /** Clears any rewind state. */ + public void clearRewind() { + savedWritePos = -1; + } + + /** + * Writes an unsigned 8-bit integer. + * + * @param value The value to write. + * @throws IOException If there is not enough space remaining in the packet. + */ + public void writeUInt8(int value) throws IOException { + checkRemaining(1); + data[pos++] = (byte) (value & 0xFF); + } + + /** + * Writes an unsigned 16-bit integer. + * + * @param value The value to write. + * @throws IOException If there is not enough space remaining in the packet. + */ + public void writeUInt16(int value) throws IOException { + checkRemaining(2); + data[pos++] = (byte) ((value >>> 8) & 0xFF); + data[pos++] = (byte) (value & 0xFF); + } + + /** + * Writes an unsigned 32-bit integer. + * + * @param value The value to write. + * @throws IOException If there is not enough space remaining in the packet. + */ + public void writeUInt32(long value) throws IOException { + checkRemaining(4); + data[pos++] = (byte) ((value >>> 24) & 0xFF); + data[pos++] = (byte) ((value >>> 16) & 0xFF); + data[pos++] = (byte) ((value >>> 8) & 0xFF); + data[pos++] = (byte) (value & 0xFF); + } + + /** + * Writes a specific number of bytes. + * + * @param data The array to write. + * @throws IOException If there is not enough space remaining in the packet. + */ + public void writeBytes(byte[] data) throws IOException { + checkRemaining(data.length); + System.arraycopy(data, 0, this.data, pos, data.length); + pos += data.length; + } + + /** + * Writes a string. + * + * @param value The string to write. + * @throws IOException If there is not enough space remaining in the packet. + */ + public void writeString(String value) throws IOException { + byte[] utf8 = value.getBytes(MdnsConstants.getUtf8Charset()); + writeUInt8(utf8.length); + writeBytes(utf8); + } + + /** + * Writes a series of labels. Uses name compression. + * + * @param labels The labels to write. + * @throws IOException If there is not enough space remaining in the packet. + */ + public void writeLabels(String[] labels) throws IOException { + // See section 4.1.4 of RFC 1035 (http://tools.ietf.org/html/rfc1035) for a description + // of the name compression method used here. + + int suffixLength = 0; + int suffixPointer = 0; + + for (Map.Entry entry : labelDictionary.entrySet()) { + int existingOffset = entry.getKey(); + String[] existingLabels = entry.getValue(); + + if (Arrays.equals(existingLabels, labels)) { + writePointer(existingOffset); + return; + } else if (MdnsRecord.labelsAreSuffix(existingLabels, labels)) { + // Keep track of the longest matching suffix so far. + if (existingLabels.length > suffixLength) { + suffixLength = existingLabels.length; + suffixPointer = existingOffset; + } + } + } + + if (suffixLength > 0) { + for (int i = 0; i < (labels.length - suffixLength); ++i) { + writeString(labels[i]); + } + writePointer(suffixPointer); + } else { + int[] offsets = new int[labels.length]; + for (int i = 0; i < labels.length; ++i) { + offsets[i] = getWritePosition(); + writeString(labels[i]); + } + writeUInt8(0); // NUL terminator + + // Add entries to the label dictionary for each suffix of the label list, including + // the whole list itself. + for (int i = 0, len = labels.length; i < labels.length; ++i, --len) { + String[] value = new String[len]; + System.arraycopy(labels, i, value, 0, len); + labelDictionary.put(offsets[i], value); + } + } + } + + /** Returns the number of bytes that can still be written. */ + public int getRemaining() { + return data.length - pos; + } + + // Writes a pointer to a label. + private void writePointer(int offset) throws IOException { + writeUInt16(MDNS_POINTER_MASK | offset); + } + + // Checks if the remaining space in the packet is at least |count|. + private void checkRemaining(int count) throws IOException { + if (getRemaining() < count) { + throw new IOException(); + } + } + + /** Builds and returns the packet. */ + public DatagramPacket getPacket(SocketAddress destAddress) throws IOException { + return new DatagramPacket(data, pos, destAddress); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsPointerRecord.java b/service/mdns/com/android/server/connectivity/mdns/MdnsPointerRecord.java new file mode 100644 index 0000000000..2b36a3c754 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsPointerRecord.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.Arrays; + +/** An mDNS "PTR" record, which holds a name (the "pointer"). */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +@VisibleForTesting +public class MdnsPointerRecord extends MdnsRecord { + private String[] pointer; + + public MdnsPointerRecord(String[] name, MdnsPacketReader reader) throws IOException { + super(name, TYPE_PTR, reader); + } + + /** Returns the pointer as an array of labels. */ + public String[] getPointer() { + return pointer; + } + + @Override + protected void readData(MdnsPacketReader reader) throws IOException { + pointer = reader.readLabels(); + } + + @Override + protected void writeData(MdnsPacketWriter writer) throws IOException { + writer.writeLabels(pointer); + } + + public boolean hasSubtype() { + return (name != null) && (name.length > 2) && name[1].equals(MdnsConstants.SUBTYPE_LABEL); + } + + public String getSubtype() { + return hasSubtype() ? name[0] : null; + } + + @Override + public String toString() { + return "PTR: " + labelsToString(name) + " -> " + labelsToString(pointer); + } + + @Override + public int hashCode() { + return (super.hashCode() * 31) + Arrays.hashCode(pointer); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof MdnsPointerRecord)) { + return false; + } + + return super.equals(other) && Arrays.equals(pointer, ((MdnsPointerRecord) other).pointer); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsRecord.java b/service/mdns/com/android/server/connectivity/mdns/MdnsRecord.java new file mode 100644 index 0000000000..4bfdb2cddc --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsRecord.java @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.os.SystemClock; +import android.text.TextUtils; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Abstract base class for mDNS records. Stores the header fields and provides methods for reading + * the record from and writing it to a packet. + */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +public abstract class MdnsRecord { + public static final int TYPE_A = 0x0001; + public static final int TYPE_AAAA = 0x001C; + public static final int TYPE_PTR = 0x000C; + public static final int TYPE_SRV = 0x0021; + public static final int TYPE_TXT = 0x0010; + + /** Status indicating that the record is current. */ + public static final int STATUS_OK = 0; + /** Status indicating that the record has expired (TTL reached 0). */ + public static final int STATUS_EXPIRED = 1; + /** Status indicating that the record should be refreshed (Less than half of TTL remains.) */ + public static final int STATUS_NEEDS_REFRESH = 2; + + protected final String[] name; + private final int type; + private final int cls; + private final long receiptTimeMillis; + private final long ttlMillis; + private Object key; + + /** + * Constructs a new record with the given name and type. + * + * @param reader The reader to read the record from. + * @throws IOException If an error occurs while reading the packet. + */ + protected MdnsRecord(String[] name, int type, MdnsPacketReader reader) throws IOException { + this.name = name; + this.type = type; + cls = reader.readUInt16(); + ttlMillis = TimeUnit.SECONDS.toMillis(reader.readUInt32()); + int dataLength = reader.readUInt16(); + + receiptTimeMillis = SystemClock.elapsedRealtime(); + + reader.setLimit(dataLength); + readData(reader); + reader.clearLimit(); + } + + /** + * Converts an array of labels into their dot-separated string representation. This method + * should + * be used for logging purposes only. + */ + public static String labelsToString(String[] labels) { + if (labels == null) { + return null; + } + return TextUtils.join(".", labels); + } + + /** Tests if |list1| is a suffix of |list2|. */ + public static boolean labelsAreSuffix(String[] list1, String[] list2) { + int offset = list2.length - list1.length; + + if (offset < 1) { + return false; + } + + for (int i = 0; i < list1.length; ++i) { + if (!list1[i].equals(list2[i + offset])) { + return false; + } + } + + return true; + } + + /** Returns the record's receipt (creation) time. */ + public final long getReceiptTime() { + return receiptTimeMillis; + } + + /** Returns the record's name. */ + public String[] getName() { + return name; + } + + /** Returns the record's original TTL, in milliseconds. */ + public final long getTtl() { + return ttlMillis; + } + + /** Returns the record's type. */ + public final int getType() { + return type; + } + + /** + * Returns the record's remaining TTL. + * + * @param now The current system time. + * @return The remaning TTL, in milliseconds. + */ + public long getRemainingTTL(final long now) { + long age = now - receiptTimeMillis; + if (age > ttlMillis) { + return 0; + } + + return ttlMillis - age; + } + + /** + * Reads the record's payload from a packet. + * + * @param reader The reader to use. + * @throws IOException If an I/O error occurs. + */ + protected abstract void readData(MdnsPacketReader reader) throws IOException; + + /** + * Writes the record to a packet. + * + * @param writer The writer to use. + * @param now The current system time. This is used when writing the updated TTL. + */ + @VisibleForTesting + public final void write(MdnsPacketWriter writer, long now) throws IOException { + writer.writeLabels(name); + writer.writeUInt16(type); + writer.writeUInt16(cls); + + writer.writeUInt32(TimeUnit.MILLISECONDS.toSeconds(getRemainingTTL(now))); + + int dataLengthPos = writer.getWritePosition(); + writer.writeUInt16(0); // data length + int dataPos = writer.getWritePosition(); + + writeData(writer); + + // Calculate amount of data written, and overwrite the data field earlier in the packet. + int endPos = writer.getWritePosition(); + int dataLength = endPos - dataPos; + writer.rewind(dataLengthPos); + writer.writeUInt16(dataLength); + writer.unrewind(); + } + + /** + * Writes the record's payload to a packet. + * + * @param writer The writer to use. + * @throws IOException If an I/O error occurs. + */ + protected abstract void writeData(MdnsPacketWriter writer) throws IOException; + + /** Gets the status of the record. */ + public int getStatus(final long now) { + final long age = now - receiptTimeMillis; + if (age > ttlMillis) { + return STATUS_EXPIRED; + } + if (age > (ttlMillis / 2)) { + return STATUS_NEEDS_REFRESH; + } + return STATUS_OK; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof MdnsRecord)) { + return false; + } + + MdnsRecord otherRecord = (MdnsRecord) other; + + return Arrays.equals(name, otherRecord.name) && (type == otherRecord.type); + } + + @Override + public int hashCode() { + return Objects.hash(Arrays.hashCode(name), type); + } + + /** + * Returns an opaque object that uniquely identifies this record through a combination of its + * type + * and name. Suitable for use as a key in caches. + */ + public final Object getKey() { + if (key == null) { + key = new Key(type, name); + } + return key; + } + + private static final class Key { + private final int recordType; + private final String[] recordName; + + public Key(int recordType, String[] recordName) { + this.recordType = recordType; + this.recordName = recordName; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Key)) { + return false; + } + + Key otherKey = (Key) other; + + return (recordType == otherKey.recordType) && Arrays.equals(recordName, + otherKey.recordName); + } + + @Override + public int hashCode() { + return (recordType * 31) + Arrays.hashCode(recordName); + } + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsResponse.java b/service/mdns/com/android/server/connectivity/mdns/MdnsResponse.java new file mode 100644 index 0000000000..1305e0764a --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsResponse.java @@ -0,0 +1,380 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +/** An mDNS response. */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +public class MdnsResponse { + private final List records; + private final List pointerRecords; + private MdnsServiceRecord serviceRecord; + private MdnsTextRecord textRecord; + private MdnsInetAddressRecord inet4AddressRecord; + private MdnsInetAddressRecord inet6AddressRecord; + private long lastUpdateTime; + + /** Constructs a new, empty response. */ + public MdnsResponse(long now) { + lastUpdateTime = now; + records = new LinkedList<>(); + pointerRecords = new LinkedList<>(); + } + + // This generic typed helper compares records for equality. + // Returns True if records are the same. + private boolean recordsAreSame(T a, T b) { + return ((a == null) && (b == null)) || ((a != null) && (b != null) && a.equals(b)); + } + + /** + * Adds a pointer record. + * + * @return true if the record was added, or false if a matching + * pointer + * record is already present in the response. + */ + public synchronized boolean addPointerRecord(MdnsPointerRecord pointerRecord) { + if (!pointerRecords.contains(pointerRecord)) { + pointerRecords.add(pointerRecord); + records.add(pointerRecord); + return true; + } + + return false; + } + + /** Gets the pointer records. */ + public synchronized List getPointerRecords() { + // Returns a shallow copy. + return new LinkedList<>(pointerRecords); + } + + public synchronized boolean hasPointerRecords() { + return !pointerRecords.isEmpty(); + } + + @VisibleForTesting + /* package */ synchronized void clearPointerRecords() { + pointerRecords.clear(); + } + + public synchronized boolean hasSubtypes() { + for (MdnsPointerRecord pointerRecord : pointerRecords) { + if (pointerRecord.hasSubtype()) { + return true; + } + } + return false; + } + + public synchronized List getSubtypes() { + List subtypes = null; + + for (MdnsPointerRecord pointerRecord : pointerRecords) { + if (pointerRecord.hasSubtype()) { + if (subtypes == null) { + subtypes = new LinkedList<>(); + } + subtypes.add(pointerRecord.getSubtype()); + } + } + + return subtypes; + } + + @VisibleForTesting + public synchronized void removeSubtypes() { + Iterator iter = pointerRecords.iterator(); + while (iter.hasNext()) { + MdnsPointerRecord pointerRecord = iter.next(); + if (pointerRecord.hasSubtype()) { + iter.remove(); + } + } + } + + /** Sets the service record. */ + public synchronized boolean setServiceRecord(MdnsServiceRecord serviceRecord) { + if (recordsAreSame(this.serviceRecord, serviceRecord)) { + return false; + } + if (this.serviceRecord != null) { + records.remove(this.serviceRecord); + } + this.serviceRecord = serviceRecord; + if (this.serviceRecord != null) { + records.add(this.serviceRecord); + } + return true; + } + + /** Gets the service record. */ + public synchronized MdnsServiceRecord getServiceRecord() { + return serviceRecord; + } + + public synchronized boolean hasServiceRecord() { + return serviceRecord != null; + } + + /** Sets the text record. */ + public synchronized boolean setTextRecord(MdnsTextRecord textRecord) { + if (recordsAreSame(this.textRecord, textRecord)) { + return false; + } + if (this.textRecord != null) { + records.remove(this.textRecord); + } + this.textRecord = textRecord; + if (this.textRecord != null) { + records.add(this.textRecord); + } + return true; + } + + /** Gets the text record. */ + public synchronized MdnsTextRecord getTextRecord() { + return textRecord; + } + + public synchronized boolean hasTextRecord() { + return textRecord != null; + } + + /** Sets the IPv4 address record. */ + public synchronized boolean setInet4AddressRecord(MdnsInetAddressRecord newInet4AddressRecord) { + if (recordsAreSame(this.inet4AddressRecord, newInet4AddressRecord)) { + return false; + } + if (this.inet4AddressRecord != null) { + records.remove(this.inet4AddressRecord); + } + if (newInet4AddressRecord != null && newInet4AddressRecord.getInet4Address() != null) { + this.inet4AddressRecord = newInet4AddressRecord; + records.add(this.inet4AddressRecord); + } + return true; + } + + /** Gets the IPv4 address record. */ + public synchronized MdnsInetAddressRecord getInet4AddressRecord() { + return inet4AddressRecord; + } + + public synchronized boolean hasInet4AddressRecord() { + return inet4AddressRecord != null; + } + + /** Sets the IPv6 address record. */ + public synchronized boolean setInet6AddressRecord(MdnsInetAddressRecord newInet6AddressRecord) { + if (recordsAreSame(this.inet6AddressRecord, newInet6AddressRecord)) { + return false; + } + if (this.inet6AddressRecord != null) { + records.remove(this.inet6AddressRecord); + } + if (newInet6AddressRecord != null && newInet6AddressRecord.getInet6Address() != null) { + this.inet6AddressRecord = newInet6AddressRecord; + records.add(this.inet6AddressRecord); + } + return true; + } + + + /** Gets the IPv6 address record. */ + public synchronized MdnsInetAddressRecord getInet6AddressRecord() { + return inet6AddressRecord; + } + + public synchronized boolean hasInet6AddressRecord() { + return inet6AddressRecord != null; + } + + /** Gets all of the records. */ + public synchronized List getRecords() { + return new LinkedList<>(records); + } + + /** + * Merges any records that are present in another response into this one. + * + * @return true if any records were added or updated. + */ + public synchronized boolean mergeRecordsFrom(MdnsResponse other) { + lastUpdateTime = other.lastUpdateTime; + + boolean updated = false; + + List pointerRecords = other.getPointerRecords(); + if (pointerRecords != null) { + for (MdnsPointerRecord pointerRecord : pointerRecords) { + if (addPointerRecord(pointerRecord)) { + updated = true; + } + } + } + + MdnsServiceRecord serviceRecord = other.getServiceRecord(); + if (serviceRecord != null) { + if (setServiceRecord(serviceRecord)) { + updated = true; + } + } + + MdnsTextRecord textRecord = other.getTextRecord(); + if (textRecord != null) { + if (setTextRecord(textRecord)) { + updated = true; + } + } + + MdnsInetAddressRecord otherInet4AddressRecord = other.getInet4AddressRecord(); + if (otherInet4AddressRecord != null && otherInet4AddressRecord.getInet4Address() != null) { + if (setInet4AddressRecord(otherInet4AddressRecord)) { + updated = true; + } + } + + MdnsInetAddressRecord otherInet6AddressRecord = other.getInet6AddressRecord(); + if (otherInet6AddressRecord != null && otherInet6AddressRecord.getInet6Address() != null) { + if (setInet6AddressRecord(otherInet6AddressRecord)) { + updated = true; + } + } + + // If the hostname in the service record no longer matches the hostname in either of the + // address records, then drop the address records. + if (this.serviceRecord != null) { + boolean dropAddressRecords = false; + + if (this.inet4AddressRecord != null) { + if (!Arrays.equals( + this.serviceRecord.getServiceHost(), this.inet4AddressRecord.getName())) { + dropAddressRecords = true; + } + } + if (this.inet6AddressRecord != null) { + if (!Arrays.equals( + this.serviceRecord.getServiceHost(), this.inet6AddressRecord.getName())) { + dropAddressRecords = true; + } + } + + if (dropAddressRecords) { + setInet4AddressRecord(null); + setInet6AddressRecord(null); + updated = true; + } + } + + return updated; + } + + /** + * Tests if the response is complete. A response is considered complete if it contains PTR, SRV, + * TXT, and A (for IPv4) or AAAA (for IPv6) records. + */ + public synchronized boolean isComplete() { + return !pointerRecords.isEmpty() + && (serviceRecord != null) + && (textRecord != null) + && (inet4AddressRecord != null || inet6AddressRecord != null); + } + + /** + * Returns the key for this response. The key uniquely identifies the response by its service + * name. + */ + public synchronized String getServiceInstanceName() { + if (pointerRecords.isEmpty()) { + return null; + } + String[] pointers = pointerRecords.get(0).getPointer(); + return ((pointers != null) && (pointers.length > 0)) ? pointers[0] : null; + } + + /** + * Tests if this response is a goodbye message. This will be true if a service record is present + * and any of the records have a TTL of 0. + */ + public synchronized boolean isGoodbye() { + if (getServiceInstanceName() != null) { + for (MdnsRecord record : records) { + // Expiring PTR records with subtypes just signal a change in known supported + // criteria, not the device itself going offline, so ignore those. + if ((record instanceof MdnsPointerRecord) + && ((MdnsPointerRecord) record).hasSubtype()) { + continue; + } + + if (record.getTtl() == 0) { + return true; + } + } + } + return false; + } + + /** + * Writes the response to a packet. + * + * @param writer The writer to use. + * @param now The current time. This is used to write updated TTLs that reflect the remaining + * TTL + * since the response was received. + * @return The number of records that were written. + * @throws IOException If an error occurred while writing (typically indicating overflow). + */ + public synchronized int write(MdnsPacketWriter writer, long now) throws IOException { + int count = 0; + for (MdnsPointerRecord pointerRecord : pointerRecords) { + pointerRecord.write(writer, now); + ++count; + } + + if (serviceRecord != null) { + serviceRecord.write(writer, now); + ++count; + } + + if (textRecord != null) { + textRecord.write(writer, now); + ++count; + } + + if (inet4AddressRecord != null) { + inet4AddressRecord.write(writer, now); + ++count; + } + + if (inet6AddressRecord != null) { + inet6AddressRecord.write(writer, now); + ++count; + } + + return count; + } +} diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsResponseDecoder.java b/service/mdns/com/android/server/connectivity/mdns/MdnsResponseDecoder.java new file mode 100644 index 0000000000..72c3156642 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsResponseDecoder.java @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.os.SystemClock; + +import com.android.server.connectivity.mdns.util.MdnsLogger; + +import java.io.EOFException; +import java.io.IOException; +import java.net.DatagramPacket; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +/** A class that decodes mDNS responses from UDP packets. */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +public class MdnsResponseDecoder { + + public static final int SUCCESS = 0; + private static final String TAG = "MdnsResponseDecoder"; + private static final MdnsLogger LOGGER = new MdnsLogger(TAG); + private final String[] serviceType; + private final Clock clock; + + /** Constructs a new decoder that will extract responses for the given service type. */ + public MdnsResponseDecoder(@NonNull Clock clock, @Nullable String[] serviceType) { + this.clock = clock; + this.serviceType = serviceType; + } + + private static void skipMdnsRecord(MdnsPacketReader reader) throws IOException { + reader.skip(2 + 4); // skip the class and TTL + int dataLength = reader.readUInt16(); + reader.skip(dataLength); + } + + private static MdnsResponse findResponseWithPointer( + List responses, String[] pointer) { + if (responses != null) { + for (MdnsResponse response : responses) { + List pointerRecords = response.getPointerRecords(); + if (pointerRecords == null) { + continue; + } + for (MdnsPointerRecord pointerRecord : pointerRecords) { + if (Arrays.equals(pointerRecord.getPointer(), pointer)) { + return response; + } + } + } + } + return null; + } + + private static MdnsResponse findResponseWithHostName( + List responses, String[] hostName) { + if (responses != null) { + for (MdnsResponse response : responses) { + MdnsServiceRecord serviceRecord = response.getServiceRecord(); + if (serviceRecord == null) { + continue; + } + if (Arrays.equals(serviceRecord.getServiceHost(), hostName)) { + return response; + } + } + } + return null; + } + + /** + * Decodes all mDNS responses for the desired service type from a packet. The class does not + * check + * the responses for completeness; the caller should do that. + * + * @param packet The packet to read from. + * @return A list of mDNS responses, or null if the packet contained no appropriate responses. + */ + public int decode(@NonNull DatagramPacket packet, @NonNull List responses) { + MdnsPacketReader reader = new MdnsPacketReader(packet); + + List records; + try { + reader.readUInt16(); // transaction ID (not used) + int flags = reader.readUInt16(); + if ((flags & MdnsConstants.FLAGS_RESPONSE_MASK) != MdnsConstants.FLAGS_RESPONSE) { + return MdnsResponseErrorCode.ERROR_NOT_RESPONSE_MESSAGE; + } + + int numQuestions = reader.readUInt16(); + int numAnswers = reader.readUInt16(); + int numAuthority = reader.readUInt16(); + int numRecords = reader.readUInt16(); + + LOGGER.log(String.format( + "num questions: %d, num answers: %d, num authority: %d, num records: %d", + numQuestions, numAnswers, numAuthority, numRecords)); + + if (numAnswers < 1) { + return MdnsResponseErrorCode.ERROR_NO_ANSWERS; + } + + records = new LinkedList<>(); + + for (int i = 0; i < (numAnswers + numAuthority + numRecords); ++i) { + String[] name; + try { + name = reader.readLabels(); + } catch (IOException e) { + LOGGER.e("Failed to read labels from mDNS response.", e); + return MdnsResponseErrorCode.ERROR_READING_RECORD_NAME; + } + int type = reader.readUInt16(); + + switch (type) { + case MdnsRecord.TYPE_A: { + try { + records.add(new MdnsInetAddressRecord(name, MdnsRecord.TYPE_A, reader)); + } catch (IOException e) { + LOGGER.e("Failed to read A record from mDNS response.", e); + return MdnsResponseErrorCode.ERROR_READING_A_RDATA; + } + break; + } + + case MdnsRecord.TYPE_AAAA: { + try { + // AAAA should only contain the IPv6 address. + MdnsInetAddressRecord record = + new MdnsInetAddressRecord(name, MdnsRecord.TYPE_AAAA, reader); + if (record.getInet6Address() != null) { + records.add(record); + } + } catch (IOException e) { + LOGGER.e("Failed to read AAAA record from mDNS response.", e); + return MdnsResponseErrorCode.ERROR_READING_AAAA_RDATA; + } + break; + } + + case MdnsRecord.TYPE_PTR: { + try { + records.add(new MdnsPointerRecord(name, reader)); + } catch (IOException e) { + LOGGER.e("Failed to read PTR record from mDNS response.", e); + return MdnsResponseErrorCode.ERROR_READING_PTR_RDATA; + } + break; + } + + case MdnsRecord.TYPE_SRV: { + if (name.length == 4) { + try { + records.add(new MdnsServiceRecord(name, reader)); + } catch (IOException e) { + LOGGER.e("Failed to read SRV record from mDNS response.", e); + return MdnsResponseErrorCode.ERROR_READING_SRV_RDATA; + } + } else { + try { + skipMdnsRecord(reader); + } catch (IOException e) { + LOGGER.e("Failed to skip SVR record from mDNS response.", e); + return MdnsResponseErrorCode.ERROR_SKIPPING_SRV_RDATA; + } + } + break; + } + + case MdnsRecord.TYPE_TXT: { + try { + records.add(new MdnsTextRecord(name, reader)); + } catch (IOException e) { + LOGGER.e("Failed to read TXT record from mDNS response.", e); + return MdnsResponseErrorCode.ERROR_READING_TXT_RDATA; + } + break; + } + + default: { + try { + skipMdnsRecord(reader); + } catch (IOException e) { + LOGGER.e("Failed to skip mDNS record.", e); + return MdnsResponseErrorCode.ERROR_SKIPPING_UNKNOWN_RECORD; + } + } + } + } + } catch (EOFException e) { + LOGGER.e("Reached the end of the mDNS response unexpectedly.", e); + return MdnsResponseErrorCode.ERROR_END_OF_FILE; + } + + // The response records are structured in a hierarchy, where some records reference + // others, as follows: + // + // PTR + // / \ + // / \ + // TXT SRV + // / \ + // / \ + // A AAAA + // + // But the order in which these records appear in the response packet is completely + // arbitrary. This means that we need to rescan the record list to construct each level of + // this hierarchy. + // + // PTR: service type -> service instance name + // + // SRV: service instance name -> host name (priority, weight) + // + // TXT: service instance name -> machine readable txt entries. + // + // A: host name -> IP address + + // Loop 1: find PTR records, which identify distinct service instances. + long now = SystemClock.elapsedRealtime(); + for (MdnsRecord record : records) { + if (record instanceof MdnsPointerRecord) { + String[] name = record.getName(); + if ((serviceType == null) + || Arrays.equals(name, serviceType) + || ((name.length == (serviceType.length + 2)) + && name[1].equals(MdnsConstants.SUBTYPE_LABEL) + && MdnsRecord.labelsAreSuffix(serviceType, name))) { + MdnsPointerRecord pointerRecord = (MdnsPointerRecord) record; + // Group PTR records that refer to the same service instance name into a single + // response. + MdnsResponse response = findResponseWithPointer(responses, + pointerRecord.getPointer()); + if (response == null) { + response = new MdnsResponse(now); + responses.add(response); + } + response.addPointerRecord((MdnsPointerRecord) record); + } + } + } + + // Loop 2: find SRV and TXT records, which reference the pointer in the PTR record. + for (MdnsRecord record : records) { + if (record instanceof MdnsServiceRecord) { + MdnsServiceRecord serviceRecord = (MdnsServiceRecord) record; + MdnsResponse response = findResponseWithPointer(responses, serviceRecord.getName()); + if (response != null) { + response.setServiceRecord(serviceRecord); + } + } else if (record instanceof MdnsTextRecord) { + MdnsTextRecord textRecord = (MdnsTextRecord) record; + MdnsResponse response = findResponseWithPointer(responses, textRecord.getName()); + if (response != null) { + response.setTextRecord(textRecord); + } + } + } + + // Loop 3: find A and AAAA records, which reference the host name in the SRV record. + for (MdnsRecord record : records) { + if (record instanceof MdnsInetAddressRecord) { + MdnsInetAddressRecord inetRecord = (MdnsInetAddressRecord) record; + MdnsResponse response = findResponseWithHostName(responses, inetRecord.getName()); + if (inetRecord.getInet4Address() != null && response != null) { + response.setInet4AddressRecord(inetRecord); + } else if (inetRecord.getInet6Address() != null && response != null) { + response.setInet6AddressRecord(inetRecord); + } + } + } + + return SUCCESS; + } + + public static class Clock { + public long elapsedRealtime() { + return SystemClock.elapsedRealtime(); + } + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsResponseErrorCode.java b/service/mdns/com/android/server/connectivity/mdns/MdnsResponseErrorCode.java new file mode 100644 index 0000000000..fcf90585d8 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsResponseErrorCode.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +/** + * The list of error code for parsing mDNS response. + * + * @hide + */ +public class MdnsResponseErrorCode { + public static final int SUCCESS = 0; + public static final int ERROR_NOT_RESPONSE_MESSAGE = 1; + public static final int ERROR_NO_ANSWERS = 2; + public static final int ERROR_READING_RECORD_NAME = 3; + public static final int ERROR_READING_A_RDATA = 4; + public static final int ERROR_READING_AAAA_RDATA = 5; + public static final int ERROR_READING_PTR_RDATA = 6; + public static final int ERROR_SKIPPING_PTR_RDATA = 7; + public static final int ERROR_READING_SRV_RDATA = 8; + public static final int ERROR_SKIPPING_SRV_RDATA = 9; + public static final int ERROR_READING_TXT_RDATA = 10; + public static final int ERROR_SKIPPING_UNKNOWN_RECORD = 11; + public static final int ERROR_END_OF_FILE = 12; +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsSearchOptions.java b/service/mdns/com/android/server/connectivity/mdns/MdnsSearchOptions.java new file mode 100644 index 0000000000..6e90d2c5e2 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsSearchOptions.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.ArraySet; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * API configuration parameters for searching the mDNS service. + * + *

Use {@link MdnsSearchOptions.Builder} to create {@link MdnsSearchOptions}. + * + * @hide + */ +public class MdnsSearchOptions implements Parcelable { + + /** @hide */ + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public MdnsSearchOptions createFromParcel(Parcel source) { + return new MdnsSearchOptions(source.createStringArrayList(), + source.readBoolean()); + } + + @Override + public MdnsSearchOptions[] newArray(int size) { + return new MdnsSearchOptions[size]; + } + }; + private static MdnsSearchOptions defaultOptions; + private final List subtypes; + + private final boolean isPassiveMode; + + /** Parcelable constructs for a {@link MdnsServiceInfo}. */ + MdnsSearchOptions(List subtypes, boolean isPassiveMode) { + this.subtypes = new ArrayList<>(); + if (subtypes != null) { + this.subtypes.addAll(subtypes); + } + this.isPassiveMode = isPassiveMode; + } + + /** Returns a {@link Builder} for {@link MdnsSearchOptions}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** Returns a default search options. */ + public static synchronized MdnsSearchOptions getDefaultOptions() { + if (defaultOptions == null) { + defaultOptions = newBuilder().build(); + } + return defaultOptions; + } + + /** @return the list of subtypes to search. */ + public List getSubtypes() { + return subtypes; + } + + /** + * @return {@code true} if the passive mode is used. The passive mode scans less frequently in + * order to conserve battery and produce less network traffic. + */ + public boolean isPassiveMode() { + return isPassiveMode; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeStringList(subtypes); + out.writeBoolean(isPassiveMode); + } + + /** A builder to create {@link MdnsSearchOptions}. */ + public static final class Builder { + private final Set subtypes; + private boolean isPassiveMode = true; + + private Builder() { + subtypes = new ArraySet<>(); + } + + /** + * Adds a subtype to search. + * + * @param subtype the subtype to add. + */ + public Builder addSubtype(@NonNull String subtype) { + if (TextUtils.isEmpty(subtype)) { + throw new IllegalArgumentException("Empty subtype"); + } + subtypes.add(subtype); + return this; + } + + /** + * Adds a set of subtypes to search. + * + * @param subtypes The list of subtypes to add. + */ + public Builder addSubtypes(@NonNull Collection subtypes) { + this.subtypes.addAll(Objects.requireNonNull(subtypes)); + return this; + } + + /** + * Sets if the passive mode scan should be used. The passive mode scans less frequently in + * order + * to conserve battery and produce less network traffic. + * + * @param isPassiveMode If set to {@code true}, passive mode will be used. If set to {@code + * false}, active mode will be used. + */ + public Builder setIsPassiveMode(boolean isPassiveMode) { + this.isPassiveMode = isPassiveMode; + return this; + } + + /** Builds a {@link MdnsSearchOptions} with the arguments supplied to this builder. */ + public MdnsSearchOptions build() { + return new MdnsSearchOptions(new ArrayList<>(subtypes), isPassiveMode); + } + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceBrowserListener.java b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceBrowserListener.java new file mode 100644 index 0000000000..53e58d1247 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceBrowserListener.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; + +import java.util.List; + +/** + * Listener interface for mDNS service instance discovery events. + * + * @hide + */ +public interface MdnsServiceBrowserListener { + + /** + * Called when an mDNS service instance is found. + * + * @param serviceInfo The found mDNS service instance. + */ + void onServiceFound(@NonNull MdnsServiceInfo serviceInfo); + + /** + * Called when an mDNS service instance is updated. + * + * @param serviceInfo The updated mDNS service instance. + */ + void onServiceUpdated(@NonNull MdnsServiceInfo serviceInfo); + + /** + * Called when an mDNS service instance is no longer valid and removed. + * + * @param serviceInstanceName The service instance name of the removed mDNS service. + */ + void onServiceRemoved(@NonNull String serviceInstanceName); + + /** + * Called when searching for mDNS service has stopped because of an error. + * + * TODO (changed when importing code): define error constants + * + * @param error The error code of the stop reason. + */ + void onSearchStoppedWithError(int error); + + /** Called when it failed to start an mDNS service discovery process. */ + void onSearchFailedToStart(); + + /** + * Called when a mDNS service discovery query has been sent. + * + * @param subtypes The list of subtypes in the discovery query. + * @param transactionId The transaction ID of the query. + */ + void onDiscoveryQuerySent(@NonNull List subtypes, int transactionId); + + /** + * Called when an error has happened when parsing a received mDNS response packet. + * + * @param receivedPacketNumber The packet sequence number of the received packet. + * @param errorCode The error code, defined in {@link MdnsResponseErrorCode}. + */ + void onFailedToParseMdnsResponse(int receivedPacketNumber, int errorCode); +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceInfo.java b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceInfo.java new file mode 100644 index 0000000000..2e4a4e555b --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceInfo.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * A class representing a discovered mDNS service instance. + * + * @hide + */ +public class MdnsServiceInfo implements Parcelable { + + /** @hide */ + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public MdnsServiceInfo createFromParcel(Parcel source) { + return new MdnsServiceInfo( + source.readString(), + source.createStringArray(), + source.createStringArrayList(), + source.createStringArray(), + source.readInt(), + source.readString(), + source.readString(), + source.createStringArrayList()); + } + + @Override + public MdnsServiceInfo[] newArray(int size) { + return new MdnsServiceInfo[size]; + } + }; + + private final String serviceInstanceName; + private final String[] serviceType; + private final List subtypes; + private final String[] hostName; + private final int port; + private final String ipv4Address; + private final String ipv6Address; + private final Map attributes = new HashMap<>(); + List textStrings; + + /** + * Constructs a {@link MdnsServiceInfo} object with default values. + * + * @hide + */ + public MdnsServiceInfo( + String serviceInstanceName, + String[] serviceType, + List subtypes, + String[] hostName, + int port, + String ipv4Address, + String ipv6Address, + List textStrings) { + this.serviceInstanceName = serviceInstanceName; + this.serviceType = serviceType; + this.subtypes = new ArrayList<>(); + if (subtypes != null) { + this.subtypes.addAll(subtypes); + } + this.hostName = hostName; + this.port = port; + this.ipv4Address = ipv4Address; + this.ipv6Address = ipv6Address; + if (textStrings != null) { + for (String text : textStrings) { + int pos = text.indexOf('='); + if (pos < 1) { + continue; + } + attributes.put(text.substring(0, pos).toLowerCase(Locale.ENGLISH), + text.substring(++pos)); + } + } + } + + /** @return the name of this service instance. */ + public String getServiceInstanceName() { + return serviceInstanceName; + } + + /** @return the type of this service instance. */ + public String[] getServiceType() { + return serviceType; + } + + /** @return the list of subtypes supported by this service instance. */ + public List getSubtypes() { + return new ArrayList<>(subtypes); + } + + /** + * @return {@code true} if this service instance supports any subtypes. + * @return {@code false} if this service instance does not support any subtypes. + */ + public boolean hasSubtypes() { + return !subtypes.isEmpty(); + } + + /** @return the host name of this service instance. */ + public String[] getHostName() { + return hostName; + } + + /** @return the port number of this service instance. */ + public int getPort() { + return port; + } + + /** @return the IPV4 address of this service instance. */ + public String getIpv4Address() { + return ipv4Address; + } + + /** @return the IPV6 address of this service instance. */ + public String getIpv6Address() { + return ipv6Address; + } + + /** + * @return the attribute value for {@code key}. + * @return {@code null} if no attribute value exists for {@code key}. + */ + public String getAttributeByKey(@NonNull String key) { + return attributes.get(key.toLowerCase(Locale.ENGLISH)); + } + + /** @return an immutable map of all attributes. */ + public Map getAttributes() { + return Collections.unmodifiableMap(attributes); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + if (textStrings == null) { + // Lazily initialize the parcelable field mTextStrings. + textStrings = new ArrayList<>(attributes.size()); + for (Map.Entry kv : attributes.entrySet()) { + textStrings.add(String.format(Locale.ROOT, "%s=%s", kv.getKey(), kv.getValue())); + } + } + + out.writeString(serviceInstanceName); + out.writeStringArray(serviceType); + out.writeStringList(subtypes); + out.writeStringArray(hostName); + out.writeInt(port); + out.writeString(ipv4Address); + out.writeString(ipv6Address); + out.writeStringList(textStrings); + } + + @Override + public String toString() { + return String.format( + Locale.ROOT, + "Name: %s, subtypes: %s, ip: %s, port: %d", + serviceInstanceName, + TextUtils.join(",", subtypes), + ipv4Address, + port); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceRecord.java b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceRecord.java new file mode 100644 index 0000000000..51de3b208d --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceRecord.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; + +/** An mDNS "SRV" record, which contains service information. */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +@VisibleForTesting +public class MdnsServiceRecord extends MdnsRecord { + public static final int PROTO_NONE = 0; + public static final int PROTO_TCP = 1; + public static final int PROTO_UDP = 2; + private static final String PROTO_TOKEN_TCP = "_tcp"; + private static final String PROTO_TOKEN_UDP = "_udp"; + private int servicePriority; + private int serviceWeight; + private int servicePort; + private String[] serviceHost; + + public MdnsServiceRecord(String[] name, MdnsPacketReader reader) throws IOException { + super(name, TYPE_SRV, reader); + } + + /** Returns the service's port number. */ + public int getServicePort() { + return servicePort; + } + + /** Returns the service's host name. */ + public String[] getServiceHost() { + return serviceHost; + } + + /** Returns the service's priority. */ + public int getServicePriority() { + return servicePriority; + } + + /** Returns the service's weight. */ + public int getServiceWeight() { + return serviceWeight; + } + + // Format of name is ... + + /** Returns the service's instance name, which uniquely identifies the service instance. */ + public String getServiceInstanceName() { + if (name.length < 1) { + return null; + } + return name[0]; + } + + /** Returns the service's name. */ + public String getServiceName() { + if (name.length < 2) { + return null; + } + return name[1]; + } + + /** Returns the service's protocol. */ + public int getServiceProtocol() { + if (name.length < 3) { + return PROTO_NONE; + } + + String protocol = name[2]; + if (protocol.equals(PROTO_TOKEN_TCP)) { + return PROTO_TCP; + } + if (protocol.equals(PROTO_TOKEN_UDP)) { + return PROTO_UDP; + } + return PROTO_NONE; + } + + @Override + protected void readData(MdnsPacketReader reader) throws IOException { + servicePriority = reader.readUInt16(); + serviceWeight = reader.readUInt16(); + servicePort = reader.readUInt16(); + serviceHost = reader.readLabels(); + } + + @Override + protected void writeData(MdnsPacketWriter writer) throws IOException { + writer.writeUInt16(servicePriority); + writer.writeUInt16(serviceWeight); + writer.writeUInt16(servicePort); + writer.writeLabels(serviceHost); + } + + @Override + public String toString() { + return String.format( + Locale.ROOT, + "SRV: %s:%d (prio=%d, weight=%d)", + labelsToString(serviceHost), + servicePort, + servicePriority, + serviceWeight); + } + + @Override + public int hashCode() { + return (super.hashCode() * 31) + + Objects.hash(servicePriority, serviceWeight, Arrays.hashCode(serviceHost), + servicePort); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof MdnsServiceRecord)) { + return false; + } + MdnsServiceRecord otherRecord = (MdnsServiceRecord) other; + + return super.equals(other) + && (servicePriority == otherRecord.servicePriority) + && (serviceWeight == otherRecord.serviceWeight) + && Objects.equals(serviceHost, otherRecord.serviceHost) + && (servicePort == otherRecord.servicePort); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java new file mode 100644 index 0000000000..c3a86e3ae0 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java @@ -0,0 +1,370 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; +import android.text.TextUtils; +import android.util.ArraySet; +import android.util.Pair; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.connectivity.mdns.util.MdnsLogger; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Instance of this class sends and receives mDNS packets of a given service type and invoke + * registered {@link MdnsServiceBrowserListener} instances. + */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +public class MdnsServiceTypeClient { + + private static final int DEFAULT_MTU = 1500; + private static final MdnsLogger LOGGER = new MdnsLogger("MdnsServiceTypeClient"); + + private final String serviceType; + private final String[] serviceTypeLabels; + private final MdnsSocketClient socketClient; + private final ScheduledExecutorService executor; + private final Object lock = new Object(); + private final Set listeners = new ArraySet<>(); + private final Map instanceNameToResponse = new HashMap<>(); + + // The session ID increases when startSendAndReceive() is called where we schedule a + // QueryTask for + // new subtypes. It stays the same between packets for same subtypes. + private long currentSessionId = 0; + + @GuardedBy("lock") + private Future requestTaskFuture; + + /** + * Constructor of {@link MdnsServiceTypeClient}. + * + * @param socketClient Sends and receives mDNS packet. + * @param executor A {@link ScheduledExecutorService} used to schedule query tasks. + */ + public MdnsServiceTypeClient( + @NonNull String serviceType, + @NonNull MdnsSocketClient socketClient, + @NonNull ScheduledExecutorService executor) { + this.serviceType = serviceType; + this.socketClient = socketClient; + this.executor = executor; + serviceTypeLabels = TextUtils.split(serviceType, "\\."); + } + + private static MdnsServiceInfo buildMdnsServiceInfoFromResponse( + @NonNull MdnsResponse response, @NonNull String[] serviceTypeLabels) { + String[] hostName = response.getServiceRecord().getServiceHost(); + int port = response.getServiceRecord().getServicePort(); + + String ipv4Address = null; + String ipv6Address = null; + if (response.hasInet4AddressRecord()) { + ipv4Address = response.getInet4AddressRecord().getInet4Address().getHostAddress(); + } + if (response.hasInet6AddressRecord()) { + ipv6Address = response.getInet6AddressRecord().getInet6Address().getHostAddress(); + } + // TODO: Throw an error message if response doesn't have Inet6 or Inet4 address. + return new MdnsServiceInfo( + response.getServiceInstanceName(), + serviceTypeLabels, + response.getSubtypes(), + hostName, + port, + ipv4Address, + ipv6Address, + response.getTextRecord().getStrings()); + } + + /** + * Registers {@code listener} for receiving discovery event of mDNS service instances, and + * starts + * (or continue) to send mDNS queries periodically. + * + * @param listener The {@link MdnsServiceBrowserListener} to register. + * @param searchOptions {@link MdnsSearchOptions} contains the list of subtypes to discover. + */ + public void startSendAndReceive( + @NonNull MdnsServiceBrowserListener listener, + @NonNull MdnsSearchOptions searchOptions) { + synchronized (lock) { + if (!listeners.contains(listener)) { + listeners.add(listener); + for (MdnsResponse existingResponse : instanceNameToResponse.values()) { + if (existingResponse.isComplete()) { + listener.onServiceFound( + buildMdnsServiceInfoFromResponse(existingResponse, + serviceTypeLabels)); + } + } + } + // Cancel the next scheduled periodical task. + if (requestTaskFuture != null) { + requestTaskFuture.cancel(true); + } + // Keep tracking the ScheduledFuture for the task so we can cancel it if caller is not + // interested anymore. + requestTaskFuture = + executor.submit( + new QueryTask( + new QueryTaskConfig( + searchOptions.getSubtypes(), + searchOptions.isPassiveMode(), + ++currentSessionId))); + } + } + + /** + * Unregisters {@code listener} from receiving discovery event of mDNS service instances. + * + * @param listener The {@link MdnsServiceBrowserListener} to unregister. + * @return {@code true} if no listener is registered with this client after unregistering {@code + * listener}. Otherwise returns {@code false}. + */ + public boolean stopSendAndReceive(@NonNull MdnsServiceBrowserListener listener) { + synchronized (lock) { + listeners.remove(listener); + if (listeners.isEmpty() && requestTaskFuture != null) { + requestTaskFuture.cancel(true); + requestTaskFuture = null; + } + return listeners.isEmpty(); + } + } + + public String[] getServiceTypeLabels() { + return serviceTypeLabels; + } + + public synchronized void processResponse(@NonNull MdnsResponse response) { + if (response.isGoodbye()) { + onGoodbyeReceived(response.getServiceInstanceName()); + } else { + onResponseReceived(response); + } + } + + public synchronized void onFailedToParseMdnsResponse(int receivedPacketNumber, int errorCode) { + for (MdnsServiceBrowserListener listener : listeners) { + listener.onFailedToParseMdnsResponse(receivedPacketNumber, errorCode); + } + } + + private void onResponseReceived(@NonNull MdnsResponse response) { + MdnsResponse currentResponse; + currentResponse = instanceNameToResponse.get(response.getServiceInstanceName()); + + boolean newServiceFound = false; + boolean existingServiceChanged = false; + if (currentResponse == null) { + newServiceFound = true; + currentResponse = response; + instanceNameToResponse.put(response.getServiceInstanceName(), currentResponse); + } else if (currentResponse.mergeRecordsFrom(response)) { + existingServiceChanged = true; + } + if (!currentResponse.isComplete() || (!newServiceFound && !existingServiceChanged)) { + return; + } + MdnsServiceInfo serviceInfo = + buildMdnsServiceInfoFromResponse(currentResponse, serviceTypeLabels); + + for (MdnsServiceBrowserListener listener : listeners) { + if (newServiceFound) { + listener.onServiceFound(serviceInfo); + } else { + listener.onServiceUpdated(serviceInfo); + } + } + } + + private void onGoodbyeReceived(@NonNull String serviceInstanceName) { + instanceNameToResponse.remove(serviceInstanceName); + for (MdnsServiceBrowserListener listener : listeners) { + listener.onServiceRemoved(serviceInstanceName); + } + } + + @VisibleForTesting + MdnsPacketWriter createMdnsPacketWriter() { + return new MdnsPacketWriter(DEFAULT_MTU); + } + + // A configuration for the PeriodicalQueryTask that contains parameters to build a query packet. + // Call to getConfigForNextRun returns a config that can be used to build the next query task. + @VisibleForTesting + static class QueryTaskConfig { + + private static final int INITIAL_TIME_BETWEEN_BURSTS_MS = + (int) MdnsConfigs.initialTimeBetweenBurstsMs(); + private static final int TIME_BETWEEN_BURSTS_MS = (int) MdnsConfigs.timeBetweenBurstsMs(); + private static final int QUERIES_PER_BURST = (int) MdnsConfigs.queriesPerBurst(); + private static final int TIME_BETWEEN_QUERIES_IN_BURST_MS = + (int) MdnsConfigs.timeBetweenQueriesInBurstMs(); + private static final int QUERIES_PER_BURST_PASSIVE_MODE = + (int) MdnsConfigs.queriesPerBurstPassive(); + private static final int UNSIGNED_SHORT_MAX_VALUE = 65536; + // The following fields are used by QueryTask so we need to test them. + @VisibleForTesting + final List subtypes; + private final boolean alwaysAskForUnicastResponse = + MdnsConfigs.alwaysAskForUnicastResponseInEachBurst(); + private final boolean usePassiveMode; + private final long sessionId; + @VisibleForTesting + int transactionId; + @VisibleForTesting + boolean expectUnicastResponse; + private int queriesPerBurst; + private int timeBetweenBurstsInMs; + private int burstCounter; + private int timeToRunNextTaskInMs; + private boolean isFirstBurst; + + QueryTaskConfig(@NonNull Collection subtypes, boolean usePassiveMode, + long sessionId) { + this.usePassiveMode = usePassiveMode; + this.subtypes = new ArrayList<>(subtypes); + this.queriesPerBurst = QUERIES_PER_BURST; + this.burstCounter = 0; + this.transactionId = 1; + this.expectUnicastResponse = true; + this.isFirstBurst = true; + this.sessionId = sessionId; + // Config the scan frequency based on the scan mode. + if (this.usePassiveMode) { + // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and then + // in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE + // queries. + this.timeBetweenBurstsInMs = TIME_BETWEEN_BURSTS_MS; + } else { + // In active scan mode, sends a burst of QUERIES_PER_BURST queries, + // TIME_BETWEEN_QUERIES_IN_BURST_MS apart, then waits for the scan interval, and + // then repeats. The scan interval starts as INITIAL_TIME_BETWEEN_BURSTS_MS and + // doubles until it maxes out at TIME_BETWEEN_BURSTS_MS. + this.timeBetweenBurstsInMs = INITIAL_TIME_BETWEEN_BURSTS_MS; + } + } + + QueryTaskConfig getConfigForNextRun() { + if (++transactionId > UNSIGNED_SHORT_MAX_VALUE) { + transactionId = 1; + } + // Only the first query expects uni-cast response. + expectUnicastResponse = false; + if (++burstCounter == queriesPerBurst) { + burstCounter = 0; + + if (alwaysAskForUnicastResponse) { + expectUnicastResponse = true; + } + // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and + // then in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE + // queries. + if (isFirstBurst) { + isFirstBurst = false; + if (usePassiveMode) { + queriesPerBurst = QUERIES_PER_BURST_PASSIVE_MODE; + } + } + // In active scan mode, sends a burst of QUERIES_PER_BURST queries, + // TIME_BETWEEN_QUERIES_IN_BURST_MS apart, then waits for the scan interval, and + // then repeats. The scan interval starts as INITIAL_TIME_BETWEEN_BURSTS_MS and + // doubles until it maxes out at TIME_BETWEEN_BURSTS_MS. + timeToRunNextTaskInMs = timeBetweenBurstsInMs; + if (timeBetweenBurstsInMs < TIME_BETWEEN_BURSTS_MS) { + timeBetweenBurstsInMs = Math.min(timeBetweenBurstsInMs * 2, + TIME_BETWEEN_BURSTS_MS); + } + } else { + timeToRunNextTaskInMs = TIME_BETWEEN_QUERIES_IN_BURST_MS; + } + return this; + } + } + + // A FutureTask that enqueues a single query, and schedule a new FutureTask for the next task. + private class QueryTask implements Runnable { + + private final QueryTaskConfig config; + + QueryTask(@NonNull QueryTaskConfig config) { + this.config = config; + } + + @Override + public void run() { + Pair> result; + try { + result = + new EnqueueMdnsQueryCallable( + socketClient, + createMdnsPacketWriter(), + serviceType, + config.subtypes, + config.expectUnicastResponse, + config.transactionId) + .call(); + } catch (Exception e) { + LOGGER.e(String.format("Failed to run EnqueueMdnsQueryCallable for subtype: %s", + TextUtils.join(",", config.subtypes)), e); + result = null; + } + synchronized (lock) { + if (MdnsConfigs.useSessionIdToScheduleMdnsTask()) { + // In case that the task is not canceled successfully, use session ID to check + // if this task should continue to schedule more. + if (config.sessionId != currentSessionId) { + return; + } + } + + if (MdnsConfigs.shouldCancelScanTaskWhenFutureIsNull()) { + if (requestTaskFuture == null) { + // If requestTaskFuture is set to null, the task is cancelled. We can't use + // isCancelled() here because this QueryTask is different from the future + // that is returned from executor.schedule(). See b/71646910. + return; + } + } + if ((result != null)) { + for (MdnsServiceBrowserListener listener : listeners) { + listener.onDiscoveryQuerySent(result.second, result.first); + } + } + QueryTaskConfig config = this.config.getConfigForNextRun(); + requestTaskFuture = + executor.schedule( + new QueryTask(config), config.timeToRunNextTaskInMs, + TimeUnit.MILLISECONDS); + } + } + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsSocket.java b/service/mdns/com/android/server/connectivity/mdns/MdnsSocket.java new file mode 100644 index 0000000000..241a52a8f6 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsSocket.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.InetSocketAddress; +import java.net.MulticastSocket; +import java.util.List; + +/** + * {@link MdnsSocket} provides a similar interface to {@link MulticastSocket} and binds to all + * available multi-cast network interfaces. + * + * @see MulticastSocket for javadoc of each public method. + */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +public class MdnsSocket { + private static final InetSocketAddress MULTICAST_IPV4_ADDRESS = + new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), MdnsConstants.MDNS_PORT); + private static final InetSocketAddress MULTICAST_IPV6_ADDRESS = + new InetSocketAddress(MdnsConstants.getMdnsIPv6Address(), MdnsConstants.MDNS_PORT); + private static boolean isOnIPv6OnlyNetwork = false; + private final MulticastNetworkInterfaceProvider multicastNetworkInterfaceProvider; + private final MulticastSocket multicastSocket; + + public MdnsSocket( + @NonNull MulticastNetworkInterfaceProvider multicastNetworkInterfaceProvider, int port) + throws IOException { + this.multicastNetworkInterfaceProvider = multicastNetworkInterfaceProvider; + this.multicastNetworkInterfaceProvider.startWatchingConnectivityChanges(); + multicastSocket = createMulticastSocket(port); + // RFC Spec: https://tools.ietf.org/html/rfc6762 + // Time to live is set 255, which is similar to the jMDNS implementation. + multicastSocket.setTimeToLive(255); + + // TODO (changed when importing code): consider tagging the socket for data usage + isOnIPv6OnlyNetwork = false; + } + + public void send(DatagramPacket packet) throws IOException { + List networkInterfaces = + multicastNetworkInterfaceProvider.getMulticastNetworkInterfaces(); + for (NetworkInterfaceWrapper networkInterface : networkInterfaces) { + multicastSocket.setNetworkInterface(networkInterface.getNetworkInterface()); + multicastSocket.send(packet); + } + } + + public void receive(DatagramPacket packet) throws IOException { + multicastSocket.receive(packet); + } + + public void joinGroup() throws IOException { + List networkInterfaces = + multicastNetworkInterfaceProvider.getMulticastNetworkInterfaces(); + InetSocketAddress multicastAddress = MULTICAST_IPV4_ADDRESS; + if (multicastNetworkInterfaceProvider.isOnIpV6OnlyNetwork(networkInterfaces)) { + isOnIPv6OnlyNetwork = true; + multicastAddress = MULTICAST_IPV6_ADDRESS; + } else { + isOnIPv6OnlyNetwork = false; + } + for (NetworkInterfaceWrapper networkInterface : networkInterfaces) { + multicastSocket.joinGroup(multicastAddress, networkInterface.getNetworkInterface()); + } + } + + public void leaveGroup() throws IOException { + List networkInterfaces = + multicastNetworkInterfaceProvider.getMulticastNetworkInterfaces(); + InetSocketAddress multicastAddress = MULTICAST_IPV4_ADDRESS; + if (multicastNetworkInterfaceProvider.isOnIpV6OnlyNetwork(networkInterfaces)) { + multicastAddress = MULTICAST_IPV6_ADDRESS; + } + for (NetworkInterfaceWrapper networkInterface : networkInterfaces) { + multicastSocket.leaveGroup(multicastAddress, networkInterface.getNetworkInterface()); + } + } + + public void close() { + // This is a race with the use of the file descriptor (b/27403984). + multicastSocket.close(); + multicastNetworkInterfaceProvider.stopWatchingConnectivityChanges(); + } + + @VisibleForTesting + MulticastSocket createMulticastSocket(int port) throws IOException { + return new MulticastSocket(port); + } + + public boolean isOnIPv6OnlyNetwork() { + return isOnIPv6OnlyNetwork; + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsSocketClient.java b/service/mdns/com/android/server/connectivity/mdns/MdnsSocketClient.java new file mode 100644 index 0000000000..e689d6c97c --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsSocketClient.java @@ -0,0 +1,504 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.Manifest.permission; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.RequiresPermission; +import android.content.Context; +import android.net.wifi.WifiManager.MulticastLock; +import android.os.SystemClock; +import android.text.format.DateUtils; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.connectivity.mdns.util.MdnsLogger; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The {@link MdnsSocketClient} maintains separate threads to send and receive mDNS packets for all + * the requested service types. + * + *

See https://tools.ietf.org/html/rfc6763 (namely sections 4 and 5). + */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +public class MdnsSocketClient { + + private static final String TAG = "MdnsClient"; + // TODO: The following values are copied from cast module. We need to think about the + // better way to share those. + private static final String CAST_SENDER_LOG_SOURCE = "CAST_SENDER_SDK"; + private static final String CAST_PREFS_NAME = "google_cast"; + private static final String PREF_CAST_SENDER_ID = "PREF_CAST_SENDER_ID"; + private static final MdnsLogger LOGGER = new MdnsLogger(TAG); + private static final String MULTICAST_TYPE = "multicast"; + private static final String UNICAST_TYPE = "unicast"; + + private static final long SLEEP_TIME_FOR_SOCKET_THREAD_MS = + MdnsConfigs.sleepTimeForSocketThreadMs(); + // A value of 0 leads to an infinite wait. + private static final long THREAD_JOIN_TIMEOUT_MS = DateUtils.SECOND_IN_MILLIS; + private static final int RECEIVER_BUFFER_SIZE = 2048; + @VisibleForTesting + final Queue multicastPacketQueue = new ArrayDeque<>(); + @VisibleForTesting + final Queue unicastPacketQueue = new ArrayDeque<>(); + private final Context context; + private final byte[] multicastReceiverBuffer = new byte[RECEIVER_BUFFER_SIZE]; + private final byte[] unicastReceiverBuffer; + private final MdnsResponseDecoder responseDecoder; + private final MulticastLock multicastLock; + private final boolean useSeparateSocketForUnicast = + MdnsConfigs.useSeparateSocketToSendUnicastQuery(); + private final boolean checkMulticastResponse = MdnsConfigs.checkMulticastResponse(); + private final long checkMulticastResponseIntervalMs = + MdnsConfigs.checkMulticastResponseIntervalMs(); + private final Object socketLock = new Object(); + private final Object timerObject = new Object(); + // If multicast response was received in the current session. The value is reset in the + // beginning of each session. + @VisibleForTesting + boolean receivedMulticastResponse; + // If unicast response was received in the current session. The value is reset in the beginning + // of each session. + @VisibleForTesting + boolean receivedUnicastResponse; + // If the phone is the bad state where it can't receive any multicast response. + @VisibleForTesting + AtomicBoolean cannotReceiveMulticastResponse = new AtomicBoolean(false); + @VisibleForTesting + volatile Thread sendThread; + @VisibleForTesting + Thread multicastReceiveThread; + @VisibleForTesting + Thread unicastReceiveThread; + private volatile boolean shouldStopSocketLoop; + private Callback callback; + private MdnsSocket multicastSocket; + private MdnsSocket unicastSocket; + private int receivedPacketNumber = 0; + private Timer logMdnsPacketTimer; + private AtomicInteger packetsCount; + private Timer checkMulticastResponseTimer; + + public MdnsSocketClient(@NonNull Context context, @NonNull MulticastLock multicastLock) { + this.context = context; + this.multicastLock = multicastLock; + responseDecoder = new MdnsResponseDecoder(new MdnsResponseDecoder.Clock(), null); + if (useSeparateSocketForUnicast) { + unicastReceiverBuffer = new byte[RECEIVER_BUFFER_SIZE]; + } else { + unicastReceiverBuffer = null; + } + } + + public synchronized void setCallback(@Nullable Callback callback) { + this.callback = callback; + } + + @RequiresPermission(permission.CHANGE_WIFI_MULTICAST_STATE) + public synchronized void startDiscovery() throws IOException { + if (multicastSocket != null) { + LOGGER.w("Discovery is already in progress."); + return; + } + + receivedMulticastResponse = false; + receivedUnicastResponse = false; + cannotReceiveMulticastResponse.set(false); + + shouldStopSocketLoop = false; + try { + // TODO (changed when importing code): consider setting thread stats tag + multicastSocket = createMdnsSocket(MdnsConstants.MDNS_PORT); + multicastSocket.joinGroup(); + if (useSeparateSocketForUnicast) { + // For unicast, use port 0 and the system will assign it with any available port. + unicastSocket = createMdnsSocket(0); + } + multicastLock.acquire(); + } catch (IOException e) { + multicastLock.release(); + if (multicastSocket != null) { + multicastSocket.close(); + multicastSocket = null; + } + if (unicastSocket != null) { + unicastSocket.close(); + unicastSocket = null; + } + throw e; + } finally { + // TODO (changed when importing code): consider resetting thread stats tag + } + createAndStartSendThread(); + createAndStartReceiverThreads(); + } + + @RequiresPermission(permission.CHANGE_WIFI_MULTICAST_STATE) + public void stopDiscovery() { + LOGGER.log("Stop discovery."); + if (multicastSocket == null && unicastSocket == null) { + return; + } + + if (MdnsConfigs.clearMdnsPacketQueueAfterDiscoveryStops()) { + synchronized (multicastPacketQueue) { + multicastPacketQueue.clear(); + } + synchronized (unicastPacketQueue) { + unicastPacketQueue.clear(); + } + } + + multicastLock.release(); + + shouldStopSocketLoop = true; + waitForSendThreadToStop(); + waitForReceiverThreadsToStop(); + + synchronized (socketLock) { + multicastSocket = null; + unicastSocket = null; + } + + synchronized (timerObject) { + if (checkMulticastResponseTimer != null) { + checkMulticastResponseTimer.cancel(); + checkMulticastResponseTimer = null; + } + } + } + + /** Sends a mDNS request packet that asks for multicast response. */ + public void sendMulticastPacket(@NonNull DatagramPacket packet) { + sendMdnsPacket(packet, multicastPacketQueue); + } + + /** Sends a mDNS request packet that asks for unicast response. */ + public void sendUnicastPacket(DatagramPacket packet) { + if (useSeparateSocketForUnicast) { + sendMdnsPacket(packet, unicastPacketQueue); + } else { + sendMdnsPacket(packet, multicastPacketQueue); + } + } + + private void sendMdnsPacket(DatagramPacket packet, Queue packetQueueToUse) { + if (shouldStopSocketLoop && !MdnsConfigs.allowAddMdnsPacketAfterDiscoveryStops()) { + LOGGER.w("sendMdnsPacket() is called after discovery already stopped"); + return; + } + synchronized (packetQueueToUse) { + while (packetQueueToUse.size() >= MdnsConfigs.mdnsPacketQueueMaxSize()) { + packetQueueToUse.remove(); + } + packetQueueToUse.add(packet); + } + triggerSendThread(); + } + + private void createAndStartSendThread() { + if (sendThread != null) { + LOGGER.w("A socket thread already exists."); + return; + } + sendThread = new Thread(this::sendThreadMain); + sendThread.setName("mdns-send"); + sendThread.start(); + } + + private void createAndStartReceiverThreads() { + if (multicastReceiveThread != null) { + LOGGER.w("A multicast receiver thread already exists."); + return; + } + multicastReceiveThread = + new Thread(() -> receiveThreadMain(multicastReceiverBuffer, multicastSocket)); + multicastReceiveThread.setName("mdns-multicast-receive"); + multicastReceiveThread.start(); + + if (useSeparateSocketForUnicast) { + unicastReceiveThread = + new Thread(() -> receiveThreadMain(unicastReceiverBuffer, unicastSocket)); + unicastReceiveThread.setName("mdns-unicast-receive"); + unicastReceiveThread.start(); + } + } + + private void triggerSendThread() { + LOGGER.log("Trigger send thread."); + Thread sendThread = this.sendThread; + if (sendThread != null) { + sendThread.interrupt(); + } else { + LOGGER.w("Socket thread is null"); + } + } + + private void waitForReceiverThreadsToStop() { + if (multicastReceiveThread != null) { + waitForThread(multicastReceiveThread); + multicastReceiveThread = null; + } + + if (unicastReceiveThread != null) { + waitForThread(unicastReceiveThread); + unicastReceiveThread = null; + } + } + + private void waitForSendThreadToStop() { + LOGGER.log("wait For Send Thread To Stop"); + if (sendThread == null) { + LOGGER.w("socket thread is already dead."); + return; + } + waitForThread(sendThread); + sendThread = null; + } + + private void waitForThread(Thread thread) { + long startMs = SystemClock.elapsedRealtime(); + long waitMs = THREAD_JOIN_TIMEOUT_MS; + while (thread.isAlive() && (waitMs > 0)) { + try { + thread.interrupt(); + thread.join(waitMs); + if (thread.isAlive()) { + LOGGER.w("Failed to join thread: " + thread); + } + break; + } catch (InterruptedException e) { + // Compute remaining time after at least a single join call, in case the clock + // resolution is poor. + waitMs = THREAD_JOIN_TIMEOUT_MS - (SystemClock.elapsedRealtime() - startMs); + } + } + } + + private void sendThreadMain() { + List multicastPacketsToSend = new ArrayList<>(); + List unicastPacketsToSend = new ArrayList<>(); + boolean shouldThreadSleep; + try { + while (!shouldStopSocketLoop) { + try { + // Make a local copy of all packets, and clear the queue. + // Send packets that ask for multicast response. + multicastPacketsToSend.clear(); + synchronized (multicastPacketQueue) { + multicastPacketsToSend.addAll(multicastPacketQueue); + multicastPacketQueue.clear(); + } + + // Send packets that ask for unicast response. + if (useSeparateSocketForUnicast) { + unicastPacketsToSend.clear(); + synchronized (unicastPacketQueue) { + unicastPacketsToSend.addAll(unicastPacketQueue); + unicastPacketQueue.clear(); + } + } + + // Send all the packets. + sendPackets(multicastPacketsToSend, multicastSocket); + sendPackets(unicastPacketsToSend, unicastSocket); + + // Sleep ONLY if no more packets have been added to the queue, while packets + // were being sent. + synchronized (multicastPacketQueue) { + synchronized (unicastPacketQueue) { + shouldThreadSleep = + multicastPacketQueue.isEmpty() && unicastPacketQueue.isEmpty(); + } + } + if (shouldThreadSleep) { + Thread.sleep(SLEEP_TIME_FOR_SOCKET_THREAD_MS); + } + } catch (InterruptedException e) { + // Don't log the interruption as it's expected. + } + } + } finally { + LOGGER.log("Send thread stopped."); + try { + multicastSocket.leaveGroup(); + } catch (Exception t) { + LOGGER.e("Failed to leave the group.", t); + } + + // Close the socket first. This is the only way to interrupt a blocking receive. + try { + // This is a race with the use of the file descriptor (b/27403984). + multicastSocket.close(); + if (unicastSocket != null) { + unicastSocket.close(); + } + } catch (Exception t) { + LOGGER.e("Failed to close the mdns socket.", t); + } + } + } + + private void receiveThreadMain(byte[] receiverBuffer, MdnsSocket socket) { + DatagramPacket packet = new DatagramPacket(receiverBuffer, receiverBuffer.length); + + while (!shouldStopSocketLoop) { + try { + // This is a race with the use of the file descriptor (b/27403984). + synchronized (socketLock) { + // This checks is to make sure the socket was not set to null. + if (socket != null && (socket == multicastSocket || socket == unicastSocket)) { + socket.receive(packet); + } + } + + if (!shouldStopSocketLoop) { + String responseType = socket == multicastSocket ? MULTICAST_TYPE : UNICAST_TYPE; + processResponsePacket(packet, responseType); + } + } catch (IOException e) { + if (!shouldStopSocketLoop) { + LOGGER.e("Failed to receive mDNS packets.", e); + } + } + } + LOGGER.log("Receive thread stopped."); + } + + private int processResponsePacket(@NonNull DatagramPacket packet, String responseType) + throws IOException { + int packetNumber = ++receivedPacketNumber; + + List responses = new LinkedList<>(); + int errorCode = responseDecoder.decode(packet, responses); + if (errorCode == MdnsResponseDecoder.SUCCESS) { + if (responseType.equals(MULTICAST_TYPE)) { + receivedMulticastResponse = true; + if (cannotReceiveMulticastResponse.getAndSet(false)) { + // If we are already in the bad state, receiving a multicast response means + // we are recovered. + LOGGER.e( + "Recovered from the state where the phone can't receive any multicast" + + " response"); + } + } else { + receivedUnicastResponse = true; + } + for (MdnsResponse response : responses) { + String serviceInstanceName = response.getServiceInstanceName(); + LOGGER.log("mDNS %s response received: %s", responseType, serviceInstanceName); + if (callback != null) { + callback.onResponseReceived(response); + } + } + } else if (errorCode != MdnsResponseErrorCode.ERROR_NOT_RESPONSE_MESSAGE) { + LOGGER.w(String.format("Error while decoding %s packet (%d): %d", + responseType, packetNumber, errorCode)); + if (callback != null) { + callback.onFailedToParseMdnsResponse(packetNumber, errorCode); + } + } + return errorCode; + } + + @VisibleForTesting + MdnsSocket createMdnsSocket(int port) throws IOException { + return new MdnsSocket(new MulticastNetworkInterfaceProvider(context), port); + } + + private void sendPackets(List packets, MdnsSocket socket) { + String requestType = socket == multicastSocket ? "multicast" : "unicast"; + for (DatagramPacket packet : packets) { + if (shouldStopSocketLoop) { + break; + } + try { + LOGGER.log("Sending a %s mDNS packet...", requestType); + socket.send(packet); + + // Start the timer task to monitor the response. + synchronized (timerObject) { + if (socket == multicastSocket) { + if (cannotReceiveMulticastResponse.get()) { + // Don't schedule the timer task if we are already in the bad state. + return; + } + if (checkMulticastResponseTimer != null) { + // Don't schedule the timer task if it's already scheduled. + return; + } + if (checkMulticastResponse && useSeparateSocketForUnicast) { + // Only when useSeparateSocketForUnicast is true, we can tell if we + // received a multicast or unicast response. + checkMulticastResponseTimer = new Timer(); + checkMulticastResponseTimer.schedule( + new TimerTask() { + @Override + public void run() { + synchronized (timerObject) { + if (checkMulticastResponseTimer == null) { + // Discovery already stopped. + return; + } + if ((!receivedMulticastResponse) + && receivedUnicastResponse) { + LOGGER.e(String.format( + "Haven't received multicast response" + + " in the last %d ms.", + checkMulticastResponseIntervalMs)); + cannotReceiveMulticastResponse.set(true); + } + checkMulticastResponseTimer = null; + } + } + }, + checkMulticastResponseIntervalMs); + } + } + } + } catch (IOException e) { + LOGGER.e(String.format("Failed to send a %s mDNS packet.", requestType), e); + } + } + packets.clear(); + } + + public boolean isOnIPv6OnlyNetwork() { + return multicastSocket.isOnIPv6OnlyNetwork(); + } + + /** Callback for {@link MdnsSocketClient}. */ + public interface Callback { + void onResponseReceived(@NonNull MdnsResponse response); + + void onFailedToParseMdnsResponse(int receivedPacketNumber, int errorCode); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsTextRecord.java b/service/mdns/com/android/server/connectivity/mdns/MdnsTextRecord.java new file mode 100644 index 0000000000..a5b5595616 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MdnsTextRecord.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** An mDNS "TXT" record, which contains a list of text strings. */ +// TODO(b/177655645): Resolve nullness suppression. +@SuppressWarnings("nullness") +@VisibleForTesting +public class MdnsTextRecord extends MdnsRecord { + private List strings; + + public MdnsTextRecord(String[] name, MdnsPacketReader reader) throws IOException { + super(name, TYPE_TXT, reader); + } + + /** Returns the list of strings. */ + public List getStrings() { + return Collections.unmodifiableList(strings); + } + + @Override + protected void readData(MdnsPacketReader reader) throws IOException { + strings = new ArrayList<>(); + while (reader.getRemaining() > 0) { + strings.add(reader.readString()); + } + } + + @Override + protected void writeData(MdnsPacketWriter writer) throws IOException { + if (strings != null) { + for (String string : strings) { + writer.writeString(string); + } + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("TXT: {"); + if (strings != null) { + for (String string : strings) { + sb.append(' ').append(string); + } + } + sb.append("}"); + + return sb.toString(); + } + + @Override + public int hashCode() { + return (super.hashCode() * 31) + Objects.hash(strings); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof MdnsTextRecord)) { + return false; + } + + return super.equals(other) && Objects.equals(strings, ((MdnsTextRecord) other).strings); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/MulticastNetworkInterfaceProvider.java b/service/mdns/com/android/server/connectivity/mdns/MulticastNetworkInterfaceProvider.java new file mode 100644 index 0000000000..e0d8fa66c5 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/MulticastNetworkInterfaceProvider.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.connectivity.mdns.util.MdnsLogger; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; + +/** + * This class is used by the {@link MdnsSocket} to monitor the list of {@link NetworkInterface} + * instances that are currently available for multi-cast messaging. + */ +public class MulticastNetworkInterfaceProvider { + + private static final String TAG = "MdnsNIProvider"; + private static final MdnsLogger LOGGER = new MdnsLogger(TAG); + private static final boolean PREFER_IPV6 = MdnsConfigs.preferIpv6(); + + private final List multicastNetworkInterfaces = new ArrayList<>(); + // Only modifiable from tests. + @VisibleForTesting + ConnectivityMonitor connectivityMonitor; + private volatile boolean connectivityChanged = true; + + @SuppressWarnings("nullness:methodref.receiver.bound") + public MulticastNetworkInterfaceProvider(@NonNull Context context) { + // IMPORT CHANGED + this.connectivityMonitor = new ConnectivityMonitorWithConnectivityManager( + context, this::onConnectivityChanged); + } + + private void onConnectivityChanged() { + connectivityChanged = true; + } + + /** + * Starts monitoring changes of connectivity of this device, which may indicate that the list of + * network interfaces available for multi-cast messaging has changed. + */ + public void startWatchingConnectivityChanges() { + connectivityMonitor.startWatchingConnectivityChanges(); + } + + /** Stops monitoring changes of connectivity. */ + public void stopWatchingConnectivityChanges() { + connectivityMonitor.stopWatchingConnectivityChanges(); + } + + /** + * Returns the list of {@link NetworkInterfaceWrapper} instances available for multi-cast + * messaging. + */ + public synchronized List getMulticastNetworkInterfaces() { + if (connectivityChanged) { + connectivityChanged = false; + updateMulticastNetworkInterfaces(); + if (multicastNetworkInterfaces.isEmpty()) { + LOGGER.log("No network interface available for mDNS scanning."); + } + } + return new ArrayList<>(multicastNetworkInterfaces); + } + + private void updateMulticastNetworkInterfaces() { + multicastNetworkInterfaces.clear(); + List networkInterfaceWrappers = getNetworkInterfaces(); + for (NetworkInterfaceWrapper interfaceWrapper : networkInterfaceWrappers) { + if (canScanOnInterface(interfaceWrapper)) { + multicastNetworkInterfaces.add(interfaceWrapper); + } + } + } + + public boolean isOnIpV6OnlyNetwork(List networkInterfaces) { + if (networkInterfaces.isEmpty()) { + return false; + } + + // TODO(b/79866499): Remove this when the bug is resolved. + if (PREFER_IPV6) { + return true; + } + boolean hasAtleastOneIPv6Address = false; + for (NetworkInterfaceWrapper interfaceWrapper : networkInterfaces) { + for (InterfaceAddress ifAddr : interfaceWrapper.getInterfaceAddresses()) { + if (!(ifAddr.getAddress() instanceof Inet6Address)) { + return false; + } else { + hasAtleastOneIPv6Address = true; + } + } + } + return hasAtleastOneIPv6Address; + } + + @VisibleForTesting + List getNetworkInterfaces() { + List networkInterfaceWrappers = new ArrayList<>(); + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + if (interfaces != null) { + while (interfaces.hasMoreElements()) { + networkInterfaceWrappers.add( + new NetworkInterfaceWrapper(interfaces.nextElement())); + } + } + } catch (SocketException e) { + LOGGER.e("Failed to get network interfaces.", e); + } catch (NullPointerException e) { + // Android R has a bug that could lead to a NPE. See b/159277702. + LOGGER.e("Failed to call getNetworkInterfaces API", e); + } + + return networkInterfaceWrappers; + } + + private boolean canScanOnInterface(@Nullable NetworkInterfaceWrapper networkInterface) { + try { + if ((networkInterface == null) + || networkInterface.isLoopback() + || networkInterface.isPointToPoint() + || networkInterface.isVirtual() + || !networkInterface.isUp() + || !networkInterface.supportsMulticast()) { + return false; + } + return hasInet4Address(networkInterface) || hasInet6Address(networkInterface); + } catch (IOException e) { + LOGGER.e(String.format("Failed to check interface %s.", + networkInterface.getNetworkInterface().getDisplayName()), e); + } + + return false; + } + + private boolean hasInet4Address(@NonNull NetworkInterfaceWrapper networkInterface) { + for (InterfaceAddress ifAddr : networkInterface.getInterfaceAddresses()) { + if (ifAddr.getAddress() instanceof Inet4Address) { + return true; + } + } + return false; + } + + private boolean hasInet6Address(@NonNull NetworkInterfaceWrapper networkInterface) { + for (InterfaceAddress ifAddr : networkInterface.getInterfaceAddresses()) { + if (ifAddr.getAddress() instanceof Inet6Address) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/NetworkInterfaceWrapper.java b/service/mdns/com/android/server/connectivity/mdns/NetworkInterfaceWrapper.java new file mode 100644 index 0000000000..0ecae48934 --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/NetworkInterfaceWrapper.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns; + +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.List; + +/** A wrapper class of {@link NetworkInterface} to be mocked in unit tests. */ +public class NetworkInterfaceWrapper { + private final NetworkInterface networkInterface; + + public NetworkInterfaceWrapper(NetworkInterface networkInterface) { + this.networkInterface = networkInterface; + } + + public NetworkInterface getNetworkInterface() { + return networkInterface; + } + + public boolean isUp() throws SocketException { + return networkInterface.isUp(); + } + + public boolean isLoopback() throws SocketException { + return networkInterface.isLoopback(); + } + + public boolean isPointToPoint() throws SocketException { + return networkInterface.isPointToPoint(); + } + + public boolean isVirtual() { + return networkInterface.isVirtual(); + } + + public boolean supportsMulticast() throws SocketException { + return networkInterface.supportsMulticast(); + } + + public List getInterfaceAddresses() { + return networkInterface.getInterfaceAddresses(); + } + + @Override + public String toString() { + return networkInterface.toString(); + } +} \ No newline at end of file diff --git a/service/mdns/com/android/server/connectivity/mdns/util/MdnsLogger.java b/service/mdns/com/android/server/connectivity/mdns/util/MdnsLogger.java new file mode 100644 index 0000000000..31c62f59ea --- /dev/null +++ b/service/mdns/com/android/server/connectivity/mdns/util/MdnsLogger.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.connectivity.mdns.util; + +import android.net.util.SharedLog; +import android.text.TextUtils; + +/** + * The logger used in mDNS. + */ +public class MdnsLogger { + // Make this logger public for other level logging than dogfood. + public final SharedLog mLog; + + /** + * Constructs a new {@link MdnsLogger} with the given logging tag. + * + * @param tag The log tag that will be used by this logger + */ + public MdnsLogger(String tag) { + mLog = new SharedLog(tag); + } + + public void log(String message) { + mLog.log(message); + } + + public void log(String message, Object... args) { + mLog.log(message + " ; " + TextUtils.join(" ; ", args)); + } + + public void d(String message) { + mLog.log(message); + } + + public void e(String message) { + mLog.e(message); + } + + public void e(String message, Throwable e) { + mLog.e(message, e); + } + + public void w(String message) { + mLog.w(message); + } +} \ No newline at end of file diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp index 3ea27f7a7a..9d746b5b7e 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -69,6 +69,7 @@ filegroup { "java/com/android/server/connectivity/NetdEventListenerServiceTest.java", "java/com/android/server/connectivity/VpnTest.java", "java/com/android/server/net/ipmemorystore/*.java", + "java/com/android/server/connectivity/mdns/**/*.java", ] } @@ -143,6 +144,7 @@ android_test { static_libs: [ "services.core", "services.net", + "service-mdns", ], jni_libs: [ "libandroid_net_connectivity_com_android_net_module_util_jni", diff --git a/tests/unit/java/com/android/server/connectivity/mdns/ConnectivityMonitorWithConnectivityManagerTests.java b/tests/unit/java/com/android/server/connectivity/mdns/ConnectivityMonitorWithConnectivityManagerTests.java new file mode 100644 index 0000000000..f84e2d8d2f --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/ConnectivityMonitorWithConnectivityManagerTests.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.ConnectivityManager.NetworkCallback; +import android.net.Network; +import android.net.NetworkRequest; + +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** Tests for {@link ConnectivityMonitor}. */ +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class ConnectivityMonitorWithConnectivityManagerTests { + @Mock private Context mContext; + @Mock private ConnectivityMonitor.Listener mockListener; + @Mock private ConnectivityManager mConnectivityManager; + + private ConnectivityMonitorWithConnectivityManager monitor; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + doReturn(mConnectivityManager).when(mContext) + .getSystemService(Context.CONNECTIVITY_SERVICE); + monitor = new ConnectivityMonitorWithConnectivityManager(mContext, mockListener); + } + + @Test + public void testInitialState_shouldNotRegisterNetworkCallback() { + verifyNetworkCallbackRegistered(0 /* time */); + verifyNetworkCallbackUnregistered(0 /* time */); + } + + @Test + public void testStartDiscovery_shouldRegisterNetworkCallback() { + monitor.startWatchingConnectivityChanges(); + + verifyNetworkCallbackRegistered(1 /* time */); + verifyNetworkCallbackUnregistered(0 /* time */); + } + + @Test + public void testStartDiscoveryTwice_shouldRegisterOneNetworkCallback() { + monitor.startWatchingConnectivityChanges(); + monitor.startWatchingConnectivityChanges(); + + verifyNetworkCallbackRegistered(1 /* time */); + verifyNetworkCallbackUnregistered(0 /* time */); + } + + @Test + public void testStopDiscovery_shouldUnregisterNetworkCallback() { + monitor.startWatchingConnectivityChanges(); + monitor.stopWatchingConnectivityChanges(); + + verifyNetworkCallbackRegistered(1 /* time */); + verifyNetworkCallbackUnregistered(1 /* time */); + } + + @Test + public void testStopDiscoveryTwice_shouldUnregisterNetworkCallback() { + monitor.startWatchingConnectivityChanges(); + monitor.stopWatchingConnectivityChanges(); + + verifyNetworkCallbackRegistered(1 /* time */); + verifyNetworkCallbackUnregistered(1 /* time */); + } + + @Test + public void testIntentFired_shouldNotifyListener() { + InOrder inOrder = inOrder(mockListener); + monitor.startWatchingConnectivityChanges(); + + final ArgumentCaptor callbackCaptor = + ArgumentCaptor.forClass(NetworkCallback.class); + verify(mConnectivityManager, times(1)).registerNetworkCallback( + any(NetworkRequest.class), callbackCaptor.capture()); + + final NetworkCallback callback = callbackCaptor.getValue(); + final Network testNetwork = new Network(1 /* netId */); + + // Simulate network available. + callback.onAvailable(testNetwork); + inOrder.verify(mockListener).onConnectivityChanged(); + + // Simulate network lost. + callback.onLost(testNetwork); + inOrder.verify(mockListener).onConnectivityChanged(); + + // Simulate network unavailable. + callback.onUnavailable(); + inOrder.verify(mockListener).onConnectivityChanged(); + } + + private void verifyNetworkCallbackRegistered(int time) { + verify(mConnectivityManager, times(time)).registerNetworkCallback( + any(NetworkRequest.class), any(NetworkCallback.class)); + } + + private void verifyNetworkCallbackUnregistered(int time) { + verify(mConnectivityManager, times(time)) + .unregisterNetworkCallback(any(NetworkCallback.class)); + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java new file mode 100644 index 0000000000..3e3c3bf0a0 --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.NonNull; +import android.text.TextUtils; + +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.util.Collections; + +/** Tests for {@link MdnsDiscoveryManager}. */ +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsDiscoveryManagerTests { + + private static final String SERVICE_TYPE_1 = "_googlecast._tcp.local"; + private static final String SERVICE_TYPE_2 = "_test._tcp.local"; + + @Mock private ExecutorProvider executorProvider; + @Mock private MdnsSocketClient socketClient; + @Mock private MdnsServiceTypeClient mockServiceTypeClientOne; + @Mock private MdnsServiceTypeClient mockServiceTypeClientTwo; + + @Mock MdnsServiceBrowserListener mockListenerOne; + @Mock MdnsServiceBrowserListener mockListenerTwo; + private MdnsDiscoveryManager discoveryManager; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + when(mockServiceTypeClientOne.getServiceTypeLabels()) + .thenReturn(TextUtils.split(SERVICE_TYPE_1, "\\.")); + when(mockServiceTypeClientTwo.getServiceTypeLabels()) + .thenReturn(TextUtils.split(SERVICE_TYPE_2, "\\.")); + + discoveryManager = new MdnsDiscoveryManager(executorProvider, socketClient) { + @Override + MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType) { + if (serviceType.equals(SERVICE_TYPE_1)) { + return mockServiceTypeClientOne; + } else if (serviceType.equals(SERVICE_TYPE_2)) { + return mockServiceTypeClientTwo; + } + return null; + } + }; + } + + @Test + public void registerListener_unregisterListener() throws IOException { + discoveryManager.registerListener( + SERVICE_TYPE_1, mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + verify(socketClient).startDiscovery(); + verify(mockServiceTypeClientOne) + .startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + + when(mockServiceTypeClientOne.stopSendAndReceive(mockListenerOne)).thenReturn(true); + discoveryManager.unregisterListener(SERVICE_TYPE_1, mockListenerOne); + verify(mockServiceTypeClientOne).stopSendAndReceive(mockListenerOne); + verify(socketClient).stopDiscovery(); + } + + @Test + public void registerMultipleListeners() throws IOException { + discoveryManager.registerListener( + SERVICE_TYPE_1, mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + verify(socketClient).startDiscovery(); + verify(mockServiceTypeClientOne) + .startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + + discoveryManager.registerListener( + SERVICE_TYPE_2, mockListenerTwo, MdnsSearchOptions.getDefaultOptions()); + verify(mockServiceTypeClientTwo) + .startSendAndReceive(mockListenerTwo, MdnsSearchOptions.getDefaultOptions()); + } + + @Test + public void onResponseReceived() { + discoveryManager.registerListener( + SERVICE_TYPE_1, mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + discoveryManager.registerListener( + SERVICE_TYPE_2, mockListenerTwo, MdnsSearchOptions.getDefaultOptions()); + + MdnsResponse responseForServiceTypeOne = createMockResponse(SERVICE_TYPE_1); + discoveryManager.onResponseReceived(responseForServiceTypeOne); + verify(mockServiceTypeClientOne).processResponse(responseForServiceTypeOne); + + MdnsResponse responseForServiceTypeTwo = createMockResponse(SERVICE_TYPE_2); + discoveryManager.onResponseReceived(responseForServiceTypeTwo); + verify(mockServiceTypeClientTwo).processResponse(responseForServiceTypeTwo); + + MdnsResponse responseForSubtype = createMockResponse("subtype._sub._googlecast._tcp.local"); + discoveryManager.onResponseReceived(responseForSubtype); + verify(mockServiceTypeClientOne).processResponse(responseForSubtype); + } + + private MdnsResponse createMockResponse(String serviceType) { + MdnsPointerRecord mockPointerRecord = mock(MdnsPointerRecord.class); + MdnsResponse mockResponse = mock(MdnsResponse.class); + when(mockResponse.getPointerRecords()) + .thenReturn(Collections.singletonList(mockPointerRecord)); + when(mockPointerRecord.getName()).thenReturn(TextUtils.split(serviceType, "\\.")); + return mockResponse; + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsPacketReaderTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsPacketReaderTests.java new file mode 100644 index 0000000000..19d8a0054a --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsPacketReaderTests.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.util.Locale; + +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsPacketReaderTests { + + @Test + public void testLimits() throws IOException { + byte[] data = new byte[25]; + DatagramPacket datagramPacket = new DatagramPacket(data, data.length); + + // After creating a new reader, confirm that the remaining is equal to the packet length + // (or that there is no temporary limit). + MdnsPacketReader packetReader = new MdnsPacketReader(datagramPacket); + assertEquals(data.length, packetReader.getRemaining()); + + // Confirm that we can set the temporary limit to 0. + packetReader.setLimit(0); + assertEquals(0, packetReader.getRemaining()); + + // Confirm that we can clear the temporary limit, and restore to the length of the packet. + packetReader.clearLimit(); + assertEquals(data.length, packetReader.getRemaining()); + + // Confirm that we can set the temporary limit to the actual length of the packet. + // While parsing packets, it is common to set the limit to the length of the packet. + packetReader.setLimit(data.length); + assertEquals(data.length, packetReader.getRemaining()); + + // Confirm that we ignore negative limits. + packetReader.setLimit(-10); + assertEquals(data.length, packetReader.getRemaining()); + + // Confirm that we can set the temporary limit to something less than the packet length. + packetReader.setLimit(data.length / 2); + assertEquals(data.length / 2, packetReader.getRemaining()); + + // Confirm that we throw an exception if trying to set the temporary limit beyond the + // packet length. + packetReader.clearLimit(); + try { + packetReader.setLimit(data.length * 2 + 1); + fail("Should have thrown an IOException when trying to set the temporary limit beyond " + + "the packet length"); + } catch (IOException e) { + // Expected + } catch (Exception e) { + fail(String.format( + Locale.ROOT, + "Should not have thrown any other exception except " + "for IOException: %s", + e.getMessage())); + } + assertEquals(data.length, packetReader.getRemaining()); + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordTests.java new file mode 100644 index 0000000000..fdb4d4acc6 --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordTests.java @@ -0,0 +1,324 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.util.Log; + +import com.android.net.module.util.HexDump; +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetSocketAddress; +import java.util.List; + +// The record test data does not use compressed names (label pointers), since that would require +// additional data to populate the label dictionary accordingly. +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsRecordTests { + private static final String TAG = "MdnsRecordTests"; + private static final int MAX_PACKET_SIZE = 4096; + private static final InetSocketAddress MULTICAST_IPV4_ADDRESS = + new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), MdnsConstants.MDNS_PORT); + private static final InetSocketAddress MULTICAST_IPV6_ADDRESS = + new InetSocketAddress(MdnsConstants.getMdnsIPv6Address(), MdnsConstants.MDNS_PORT); + + @Test + public void testInet4AddressRecord() throws IOException { + final byte[] dataIn = HexDump.hexStringToByteArray( + "0474657374000001" + "0001000011940004" + "0A010203"); + assertNotNull(dataIn); + String dataInText = HexDump.dumpHexString(dataIn, 0, dataIn.length); + + // Decode + DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + + String[] name = reader.readLabels(); + assertNotNull(name); + assertEquals(1, name.length); + assertEquals("test", name[0]); + String fqdn = MdnsRecord.labelsToString(name); + assertEquals("test", fqdn); + + int type = reader.readUInt16(); + assertEquals(MdnsRecord.TYPE_A, type); + + MdnsInetAddressRecord record = new MdnsInetAddressRecord(name, MdnsRecord.TYPE_A, reader); + Inet4Address addr = record.getInet4Address(); + assertEquals("/10.1.2.3", addr.toString()); + + // Encode + MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE); + record.write(writer, record.getReceiptTime()); + + packet = writer.getPacket(MULTICAST_IPV4_ADDRESS); + byte[] dataOut = packet.getData(); + + String dataOutText = HexDump.dumpHexString(dataOut, 0, packet.getLength()); + Log.d(TAG, dataOutText); + + assertEquals(dataInText, dataOutText); + } + + @Test + public void testTypeAAAInet6AddressRecord() throws IOException { + final byte[] dataIn = HexDump.hexStringToByteArray( + "047465737400001C" + + "0001000011940010" + + "AABBCCDD11223344" + + "A0B0C0D010203040"); + assertNotNull(dataIn); + String dataInText = HexDump.dumpHexString(dataIn, 0, dataIn.length); + + // Decode + DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length); + packet.setSocketAddress( + new InetSocketAddress(MdnsConstants.getMdnsIPv6Address(), MdnsConstants.MDNS_PORT)); + MdnsPacketReader reader = new MdnsPacketReader(packet); + + String[] name = reader.readLabels(); + assertNotNull(name); + assertEquals(1, name.length); + String fqdn = MdnsRecord.labelsToString(name); + assertEquals("test", fqdn); + + int type = reader.readUInt16(); + assertEquals(MdnsRecord.TYPE_AAAA, type); + + MdnsInetAddressRecord record = new MdnsInetAddressRecord(name, MdnsRecord.TYPE_AAAA, + reader); + assertNull(record.getInet4Address()); + Inet6Address addr = record.getInet6Address(); + assertEquals("/aabb:ccdd:1122:3344:a0b0:c0d0:1020:3040", addr.toString()); + + // Encode + MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE); + record.write(writer, record.getReceiptTime()); + + packet = writer.getPacket(MULTICAST_IPV6_ADDRESS); + byte[] dataOut = packet.getData(); + + String dataOutText = HexDump.dumpHexString(dataOut, 0, packet.getLength()); + Log.d(TAG, dataOutText); + + assertEquals(dataInText, dataOutText); + } + + @Test + public void testTypeAAAInet4AddressRecord() throws IOException { + final byte[] dataIn = HexDump.hexStringToByteArray( + "047465737400001C" + + "0001000011940010" + + "0000000000000000" + + "0000FFFF10203040"); + assertNotNull(dataIn); + HexDump.dumpHexString(dataIn, 0, dataIn.length); + + // Decode + DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length); + packet.setSocketAddress( + new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), MdnsConstants.MDNS_PORT)); + MdnsPacketReader reader = new MdnsPacketReader(packet); + + String[] name = reader.readLabels(); + assertNotNull(name); + assertEquals(1, name.length); + String fqdn = MdnsRecord.labelsToString(name); + assertEquals("test", fqdn); + + int type = reader.readUInt16(); + assertEquals(MdnsRecord.TYPE_AAAA, type); + + MdnsInetAddressRecord record = new MdnsInetAddressRecord(name, MdnsRecord.TYPE_AAAA, + reader); + assertNull(record.getInet6Address()); + Inet4Address addr = record.getInet4Address(); + assertEquals("/16.32.48.64", addr.toString()); + + // Encode + MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE); + record.write(writer, record.getReceiptTime()); + + packet = writer.getPacket(MULTICAST_IPV4_ADDRESS); + byte[] dataOut = packet.getData(); + + String dataOutText = HexDump.dumpHexString(dataOut, 0, packet.getLength()); + Log.d(TAG, dataOutText); + + final byte[] expectedDataIn = + HexDump.hexStringToByteArray("047465737400001C000100001194000410203040"); + assertNotNull(expectedDataIn); + String expectedDataInText = HexDump.dumpHexString(expectedDataIn, 0, expectedDataIn.length); + + assertEquals(expectedDataInText, dataOutText); + } + + @Test + public void testPointerRecord() throws IOException { + final byte[] dataIn = HexDump.hexStringToByteArray( + "047465737400000C" + + "000100001194000E" + + "03666F6F03626172" + + "047175787800"); + assertNotNull(dataIn); + String dataInText = HexDump.dumpHexString(dataIn, 0, dataIn.length); + + // Decode + DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + + String[] name = reader.readLabels(); + assertNotNull(name); + assertEquals(1, name.length); + String fqdn = MdnsRecord.labelsToString(name); + assertEquals("test", fqdn); + + int type = reader.readUInt16(); + assertEquals(MdnsRecord.TYPE_PTR, type); + + MdnsPointerRecord record = new MdnsPointerRecord(name, reader); + String[] pointer = record.getPointer(); + assertEquals("foo.bar.quxx", MdnsRecord.labelsToString(pointer)); + + assertFalse(record.hasSubtype()); + assertNull(record.getSubtype()); + + // Encode + MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE); + record.write(writer, record.getReceiptTime()); + + packet = writer.getPacket(MULTICAST_IPV4_ADDRESS); + byte[] dataOut = packet.getData(); + + String dataOutText = HexDump.dumpHexString(dataOut, 0, packet.getLength()); + Log.d(TAG, dataOutText); + + assertEquals(dataInText, dataOutText); + } + + @Test + public void testServiceRecord() throws IOException { + final byte[] dataIn = HexDump.hexStringToByteArray( + "0474657374000021" + + "0001000011940014" + + "000100FF1F480366" + + "6F6F036261720471" + + "75787800"); + assertNotNull(dataIn); + String dataInText = HexDump.dumpHexString(dataIn, 0, dataIn.length); + + // Decode + DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + + String[] name = reader.readLabels(); + assertNotNull(name); + assertEquals(1, name.length); + String fqdn = MdnsRecord.labelsToString(name); + assertEquals("test", fqdn); + + int type = reader.readUInt16(); + assertEquals(MdnsRecord.TYPE_SRV, type); + + MdnsServiceRecord record = new MdnsServiceRecord(name, reader); + + int servicePort = record.getServicePort(); + assertEquals(8008, servicePort); + + String serviceHost = MdnsRecord.labelsToString(record.getServiceHost()); + assertEquals("foo.bar.quxx", serviceHost); + + assertEquals(1, record.getServicePriority()); + assertEquals(255, record.getServiceWeight()); + + // Encode + MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE); + record.write(writer, record.getReceiptTime()); + + packet = writer.getPacket(MULTICAST_IPV4_ADDRESS); + byte[] dataOut = packet.getData(); + + String dataOutText = HexDump.dumpHexString(dataOut, 0, packet.getLength()); + Log.d(TAG, dataOutText); + + assertEquals(dataInText, dataOutText); + } + + @Test + public void testTextRecord() throws IOException { + final byte[] dataIn = HexDump.hexStringToByteArray( + "0474657374000010" + + "0001000011940024" + + "0D613D68656C6C6F" + + "2074686572650C62" + + "3D31323334353637" + + "3839300878797A3D" + + "21402324"); + assertNotNull(dataIn); + String dataInText = HexDump.dumpHexString(dataIn, 0, dataIn.length); + + // Decode + DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + + String[] name = reader.readLabels(); + assertNotNull(name); + assertEquals(1, name.length); + String fqdn = MdnsRecord.labelsToString(name); + assertEquals("test", fqdn); + + int type = reader.readUInt16(); + assertEquals(MdnsRecord.TYPE_TXT, type); + + MdnsTextRecord record = new MdnsTextRecord(name, reader); + + List strings = record.getStrings(); + assertNotNull(strings); + assertEquals(3, strings.size()); + + assertEquals("a=hello there", strings.get(0)); + assertEquals("b=1234567890", strings.get(1)); + assertEquals("xyz=!@#$", strings.get(2)); + + // Encode + MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE); + record.write(writer, record.getReceiptTime()); + + packet = writer.getPacket(MULTICAST_IPV4_ADDRESS); + byte[] dataOut = packet.getData(); + + String dataOutText = HexDump.dumpHexString(dataOut, 0, packet.getLength()); + Log.d(TAG, dataOutText); + + assertEquals(dataInText, dataOutText); + } +} diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsResponseDecoderTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsResponseDecoderTests.java new file mode 100644 index 0000000000..ea9156ce13 --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsResponseDecoderTests.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.server.connectivity.mdns.MdnsResponseDecoder.Clock; +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import com.android.net.module.util.HexDump; +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetSocketAddress; +import java.util.LinkedList; +import java.util.List; + +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsResponseDecoderTests { + private static final byte[] data = HexDump.hexStringToByteArray( + "0000840000000004" + + "00000003134A6F68" + + "6E6E792773204368" + + "726F6D6563617374" + + "0B5F676F6F676C65" + + "63617374045F7463" + + "70056C6F63616C00" + + "0010800100001194" + + "006C2369643D3937" + + "3062663534376237" + + "3533666336336332" + + "6432613336626238" + + "3936616261380576" + + "653D30320D6D643D" + + "4368726F6D656361" + + "73741269633D2F73" + + "657475702F69636F" + + "6E2E706E6716666E" + + "3D4A6F686E6E7927" + + "73204368726F6D65" + + "636173740463613D" + + "350473743D30095F" + + "7365727669636573" + + "075F646E732D7364" + + "045F756470C03100" + + "0C00010000119400" + + "02C020C020000C00" + + "01000011940002C0" + + "0CC00C0021800100" + + "000078001C000000" + + "001F49134A6F686E" + + "6E79277320436872" + + "6F6D6563617374C0" + + "31C0F30001800100" + + "0000780004C0A864" + + "68C0F3002F800100" + + "0000780005C0F300" + + "0140C00C002F8001" + + "000011940009C00C" + + "00050000800040"); + + private static final byte[] data6 = HexDump.hexStringToByteArray( + "0000840000000001000000030B5F676F6F676C656361737404" + + "5F746370056C6F63616C00000C000100000078003330476F6F676C" + + "652D486F6D652D4D61782D61363836666331323961366638636265" + + "31643636353139343065336164353766C00CC02E00108001000011" + + "9400C02369643D6136383666633132396136663863626531643636" + + "3531393430653361643537662363643D4133304233303032363546" + + "36384341313233353532434639344141353742314613726D3D4335" + + "35393134383530383841313638330576653D3035126D643D476F6F" + + "676C6520486F6D65204D61781269633D2F73657475702F69636F6E" + + "2E706E6710666E3D417474696320737065616B65720863613D3130" + + "3234340473743D320F62733D464138464341363734453537046E66" + + "3D320372733DC02E0021800100000078002D000000001F49246136" + + "3836666331322D396136662D386362652D316436362D3531393430" + + "65336164353766C01DC13F001C8001000000780010200033330000" + + "0000DA6C63FFFE7C74830109018001000000780004C0A801026C6F" + + "63616C0000018001000000780004C0A8010A000001800100000078" + + "0004C0A8010A00000000000000"); + + private static final String DUMMY_CAST_SERVICE_NAME = "_googlecast"; + private static final String[] DUMMY_CAST_SERVICE_TYPE = + new String[] {DUMMY_CAST_SERVICE_NAME, "_tcp", "local"}; + + private final List responses = new LinkedList<>(); + + private final Clock mClock = mock(Clock.class); + + @Before + public void setUp() { + MdnsResponseDecoder decoder = new MdnsResponseDecoder(mClock, DUMMY_CAST_SERVICE_TYPE); + assertNotNull(data); + DatagramPacket packet = new DatagramPacket(data, data.length); + packet.setSocketAddress( + new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), MdnsConstants.MDNS_PORT)); + responses.clear(); + int errorCode = decoder.decode(packet, responses); + assertEquals(MdnsResponseDecoder.SUCCESS, errorCode); + assertEquals(1, responses.size()); + } + + @Test + public void testDecodeWithNullServiceType() { + MdnsResponseDecoder decoder = new MdnsResponseDecoder(mClock, null); + assertNotNull(data); + DatagramPacket packet = new DatagramPacket(data, data.length); + packet.setSocketAddress( + new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), MdnsConstants.MDNS_PORT)); + responses.clear(); + int errorCode = decoder.decode(packet, responses); + assertEquals(MdnsResponseDecoder.SUCCESS, errorCode); + assertEquals(2, responses.size()); + } + + @Test + public void testDecodeMultipleAnswerPacket() throws IOException { + MdnsResponse response = responses.get(0); + assertTrue(response.isComplete()); + + MdnsInetAddressRecord inet4AddressRecord = response.getInet4AddressRecord(); + Inet4Address inet4Addr = inet4AddressRecord.getInet4Address(); + + assertNotNull(inet4Addr); + assertEquals("/192.168.100.104", inet4Addr.toString()); + + MdnsServiceRecord serviceRecord = response.getServiceRecord(); + String serviceName = serviceRecord.getServiceName(); + assertEquals(DUMMY_CAST_SERVICE_NAME, serviceName); + + String serviceInstanceName = serviceRecord.getServiceInstanceName(); + assertEquals("Johnny's Chromecast", serviceInstanceName); + + String serviceHost = MdnsRecord.labelsToString(serviceRecord.getServiceHost()); + assertEquals("Johnny's Chromecast.local", serviceHost); + + int serviceProto = serviceRecord.getServiceProtocol(); + assertEquals(MdnsServiceRecord.PROTO_TCP, serviceProto); + + int servicePort = serviceRecord.getServicePort(); + assertEquals(8009, servicePort); + + int servicePriority = serviceRecord.getServicePriority(); + assertEquals(0, servicePriority); + + int serviceWeight = serviceRecord.getServiceWeight(); + assertEquals(0, serviceWeight); + + MdnsTextRecord textRecord = response.getTextRecord(); + List textStrings = textRecord.getStrings(); + assertEquals(7, textStrings.size()); + assertEquals("id=970bf547b753fc63c2d2a36bb896aba8", textStrings.get(0)); + assertEquals("ve=02", textStrings.get(1)); + assertEquals("md=Chromecast", textStrings.get(2)); + assertEquals("ic=/setup/icon.png", textStrings.get(3)); + assertEquals("fn=Johnny's Chromecast", textStrings.get(4)); + assertEquals("ca=5", textStrings.get(5)); + assertEquals("st=0", textStrings.get(6)); + } + + @Test + public void testDecodeIPv6AnswerPacket() throws IOException { + MdnsResponseDecoder decoder = new MdnsResponseDecoder(mClock, DUMMY_CAST_SERVICE_TYPE); + assertNotNull(data6); + DatagramPacket packet = new DatagramPacket(data6, data6.length); + packet.setSocketAddress( + new InetSocketAddress(MdnsConstants.getMdnsIPv6Address(), MdnsConstants.MDNS_PORT)); + + responses.clear(); + int errorCode = decoder.decode(packet, responses); + assertEquals(MdnsResponseDecoder.SUCCESS, errorCode); + + MdnsResponse response = responses.get(0); + assertTrue(response.isComplete()); + + MdnsInetAddressRecord inet6AddressRecord = response.getInet6AddressRecord(); + assertNotNull(inet6AddressRecord); + Inet4Address inet4Addr = inet6AddressRecord.getInet4Address(); + assertNull(inet4Addr); + + Inet6Address inet6Addr = inet6AddressRecord.getInet6Address(); + assertNotNull(inet6Addr); + assertEquals(inet6Addr.getHostAddress(), "2000:3333::da6c:63ff:fe7c:7483"); + } + + @Test + public void testIsComplete() { + MdnsResponse response = responses.get(0); + assertTrue(response.isComplete()); + + response.clearPointerRecords(); + assertFalse(response.isComplete()); + + response = responses.get(0); + response.setInet4AddressRecord(null); + assertFalse(response.isComplete()); + + response = responses.get(0); + response.setInet6AddressRecord(null); + assertFalse(response.isComplete()); + + response = responses.get(0); + response.setServiceRecord(null); + assertFalse(response.isComplete()); + + response = responses.get(0); + response.setTextRecord(null); + assertFalse(response.isComplete()); + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsResponseTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsResponseTests.java new file mode 100644 index 0000000000..ae16f2bf13 --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsResponseTests.java @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.android.net.module.util.HexDump; +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.util.Arrays; +import java.util.List; + +// The record test data does not use compressed names (label pointers), since that would require +// additional data to populate the label dictionary accordingly. +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsResponseTests { + private static final String TAG = "MdnsResponseTests"; + // MDNS response packet for name "test" with an IPv4 address of 10.1.2.3 + private static final byte[] dataIn_ipv4_1 = HexDump.hexStringToByteArray( + "0474657374000001" + "0001000011940004" + "0A010203"); + // MDNS response packet for name "tess" with an IPv4 address of 10.1.2.4 + private static final byte[] dataIn_ipv4_2 = HexDump.hexStringToByteArray( + "0474657373000001" + "0001000011940004" + "0A010204"); + // MDNS response w/name "test" & IPv6 address of aabb:ccdd:1122:3344:a0b0:c0d0:1020:3040 + private static final byte[] dataIn_ipv6_1 = HexDump.hexStringToByteArray( + "047465737400001C" + "0001000011940010" + "AABBCCDD11223344" + "A0B0C0D010203040"); + // MDNS response w/name "test" & IPv6 address of aabb:ccdd:1122:3344:a0b0:c0d0:1020:3030 + private static final byte[] dataIn_ipv6_2 = HexDump.hexStringToByteArray( + "047465737400001C" + "0001000011940010" + "AABBCCDD11223344" + "A0B0C0D010203030"); + // MDNS response w/name "test" & PTR to foo.bar.quxx + private static final byte[] dataIn_ptr_1 = HexDump.hexStringToByteArray( + "047465737400000C" + "000100001194000E" + "03666F6F03626172" + "047175787800"); + // MDNS response w/name "test" & PTR to foo.bar.quxy + private static final byte[] dataIn_ptr_2 = HexDump.hexStringToByteArray( + "047465737400000C" + "000100001194000E" + "03666F6F03626172" + "047175787900"); + // MDNS response w/name "test" & Service for host foo.bar.quxx + private static final byte[] dataIn_service_1 = HexDump.hexStringToByteArray( + "0474657374000021" + + "0001000011940014" + + "000100FF1F480366" + + "6F6F036261720471" + + "75787800"); + // MDNS response w/name "test" & Service for host test + private static final byte[] dataIn_service_2 = HexDump.hexStringToByteArray( + "0474657374000021" + "000100001194000B" + "000100FF1F480474" + "657374"); + // MDNS response w/name "test" & the following text strings: + // "a=hello there", "b=1234567890", and "xyz=!$$$" + private static final byte[] dataIn_text_1 = HexDump.hexStringToByteArray( + "0474657374000010" + + "0001000011940024" + + "0D613D68656C6C6F" + + "2074686572650C62" + + "3D31323334353637" + + "3839300878797A3D" + + "21242424"); + // MDNS response w/name "test" & the following text strings: + // "a=hello there", "b=1234567890", and "xyz=!@#$" + private static final byte[] dataIn_text_2 = HexDump.hexStringToByteArray( + "0474657374000010" + + "0001000011940024" + + "0D613D68656C6C6F" + + "2074686572650C62" + + "3D31323334353637" + + "3839300878797A3D" + + "21402324"); + + // The following helper classes act as wrappers so that IPv4 and IPv6 address records can + // be explicitly created by type using same constructor signature as all other records. + static class MdnsInet4AddressRecord extends MdnsInetAddressRecord { + public MdnsInet4AddressRecord(String[] name, MdnsPacketReader reader) throws IOException { + super(name, MdnsRecord.TYPE_A, reader); + } + } + + static class MdnsInet6AddressRecord extends MdnsInetAddressRecord { + public MdnsInet6AddressRecord(String[] name, MdnsPacketReader reader) throws IOException { + super(name, MdnsRecord.TYPE_AAAA, reader); + } + } + + // This helper class just wraps the data bytes of a response packet with the contained record + // type. + // Its only purpose is to make the test code a bit more readable. + static class PacketAndRecordClass { + public final byte[] packetData; + public final Class recordClass; + + public PacketAndRecordClass() { + packetData = null; + recordClass = null; + } + + public PacketAndRecordClass(byte[] data, Class c) { + packetData = data; + recordClass = c; + } + } + + // Construct an MdnsResponse with the specified data packets applied. + private MdnsResponse makeMdnsResponse(long time, List responseList) + throws IOException { + MdnsResponse response = new MdnsResponse(time); + for (PacketAndRecordClass responseData : responseList) { + DatagramPacket packet = + new DatagramPacket(responseData.packetData, responseData.packetData.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + String[] name = reader.readLabels(); + reader.skip(2); // skip record type indication. + // Apply the right kind of record to the response. + if (responseData.recordClass == MdnsInet4AddressRecord.class) { + response.setInet4AddressRecord(new MdnsInet4AddressRecord(name, reader)); + } else if (responseData.recordClass == MdnsInet6AddressRecord.class) { + response.setInet6AddressRecord(new MdnsInet6AddressRecord(name, reader)); + } else if (responseData.recordClass == MdnsPointerRecord.class) { + response.addPointerRecord(new MdnsPointerRecord(name, reader)); + } else if (responseData.recordClass == MdnsServiceRecord.class) { + response.setServiceRecord(new MdnsServiceRecord(name, reader)); + } else if (responseData.recordClass == MdnsTextRecord.class) { + response.setTextRecord(new MdnsTextRecord(name, reader)); + } else { + fail("Unsupported/unexpected MdnsRecord subtype used in test - invalid test!"); + } + } + return response; + } + + @Test + public void getInet4AddressRecord_returnsAddedRecord() throws IOException { + DatagramPacket packet = new DatagramPacket(dataIn_ipv4_1, dataIn_ipv4_1.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + String[] name = reader.readLabels(); + reader.skip(2); // skip record type indication. + MdnsInetAddressRecord record = new MdnsInetAddressRecord(name, MdnsRecord.TYPE_A, reader); + MdnsResponse response = new MdnsResponse(0); + assertFalse(response.hasInet4AddressRecord()); + assertTrue(response.setInet4AddressRecord(record)); + assertEquals(response.getInet4AddressRecord(), record); + } + + @Test + public void getInet6AddressRecord_returnsAddedRecord() throws IOException { + DatagramPacket packet = new DatagramPacket(dataIn_ipv6_1, dataIn_ipv6_1.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + String[] name = reader.readLabels(); + reader.skip(2); // skip record type indication. + MdnsInetAddressRecord record = + new MdnsInetAddressRecord(name, MdnsRecord.TYPE_AAAA, reader); + MdnsResponse response = new MdnsResponse(0); + assertFalse(response.hasInet6AddressRecord()); + assertTrue(response.setInet6AddressRecord(record)); + assertEquals(response.getInet6AddressRecord(), record); + } + + @Test + public void getPointerRecords_returnsAddedRecord() throws IOException { + DatagramPacket packet = new DatagramPacket(dataIn_ptr_1, dataIn_ptr_1.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + String[] name = reader.readLabels(); + reader.skip(2); // skip record type indication. + MdnsPointerRecord record = new MdnsPointerRecord(name, reader); + MdnsResponse response = new MdnsResponse(0); + assertFalse(response.hasPointerRecords()); + assertTrue(response.addPointerRecord(record)); + List recordList = response.getPointerRecords(); + assertNotNull(recordList); + assertEquals(1, recordList.size()); + assertEquals(record, recordList.get(0)); + } + + @Test + public void getServiceRecord_returnsAddedRecord() throws IOException { + DatagramPacket packet = new DatagramPacket(dataIn_service_1, dataIn_service_1.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + String[] name = reader.readLabels(); + reader.skip(2); // skip record type indication. + MdnsServiceRecord record = new MdnsServiceRecord(name, reader); + MdnsResponse response = new MdnsResponse(0); + assertFalse(response.hasServiceRecord()); + assertTrue(response.setServiceRecord(record)); + assertEquals(response.getServiceRecord(), record); + } + + @Test + public void getTextRecord_returnsAddedRecord() throws IOException { + DatagramPacket packet = new DatagramPacket(dataIn_text_1, dataIn_text_1.length); + MdnsPacketReader reader = new MdnsPacketReader(packet); + String[] name = reader.readLabels(); + reader.skip(2); // skip record type indication. + MdnsTextRecord record = new MdnsTextRecord(name, reader); + MdnsResponse response = new MdnsResponse(0); + assertFalse(response.hasTextRecord()); + assertTrue(response.setTextRecord(record)); + assertEquals(response.getTextRecord(), record); + } + + @Test + public void mergeRecordsFrom_indicates_change_on_ipv4_address() throws IOException { + MdnsResponse response = makeMdnsResponse( + 0, + Arrays.asList( + new PacketAndRecordClass(dataIn_ipv4_1, MdnsInet4AddressRecord.class))); + // Now create a new response that updates the address. + MdnsResponse response2 = makeMdnsResponse( + 100, + Arrays.asList( + new PacketAndRecordClass(dataIn_ipv4_2, MdnsInet4AddressRecord.class))); + assertTrue(response.mergeRecordsFrom(response2)); + } + + @Test + public void mergeRecordsFrom_indicates_change_on_ipv6_address() throws IOException { + MdnsResponse response = makeMdnsResponse( + 0, + Arrays.asList( + new PacketAndRecordClass(dataIn_ipv6_1, MdnsInet6AddressRecord.class))); + // Now create a new response that updates the address. + MdnsResponse response2 = makeMdnsResponse( + 100, + Arrays.asList( + new PacketAndRecordClass(dataIn_ipv6_2, MdnsInet6AddressRecord.class))); + assertTrue(response.mergeRecordsFrom(response2)); + } + + @Test + public void mergeRecordsFrom_indicates_change_on_text() throws IOException { + MdnsResponse response = makeMdnsResponse( + 0, + Arrays.asList(new PacketAndRecordClass(dataIn_text_1, MdnsTextRecord.class))); + // Now create a new response that updates the address. + MdnsResponse response2 = makeMdnsResponse( + 100, + Arrays.asList(new PacketAndRecordClass(dataIn_text_2, MdnsTextRecord.class))); + assertTrue(response.mergeRecordsFrom(response2)); + } + + @Test + public void mergeRecordsFrom_indicates_change_on_service() throws IOException { + MdnsResponse response = makeMdnsResponse( + 0, + Arrays.asList(new PacketAndRecordClass(dataIn_service_1, MdnsServiceRecord.class))); + // Now create a new response that updates the address. + MdnsResponse response2 = makeMdnsResponse( + 100, + Arrays.asList(new PacketAndRecordClass(dataIn_service_2, MdnsServiceRecord.class))); + assertTrue(response.mergeRecordsFrom(response2)); + } + + @Test + public void mergeRecordsFrom_indicates_change_on_pointer() throws IOException { + MdnsResponse response = makeMdnsResponse( + 0, + Arrays.asList(new PacketAndRecordClass(dataIn_ptr_1, MdnsPointerRecord.class))); + // Now create a new response that updates the address. + MdnsResponse response2 = makeMdnsResponse( + 100, + Arrays.asList(new PacketAndRecordClass(dataIn_ptr_2, MdnsPointerRecord.class))); + assertTrue(response.mergeRecordsFrom(response2)); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void mergeRecordsFrom_indicates_noChange() throws IOException { + //MdnsConfigsFlagsImpl.useReducedMergeRecordUpdateEvents.override(true); + List recordList = + Arrays.asList( + new PacketAndRecordClass(dataIn_ipv4_1, MdnsInet4AddressRecord.class), + new PacketAndRecordClass(dataIn_ipv6_1, MdnsInet6AddressRecord.class), + new PacketAndRecordClass(dataIn_ptr_1, MdnsPointerRecord.class), + new PacketAndRecordClass(dataIn_service_2, MdnsServiceRecord.class), + new PacketAndRecordClass(dataIn_text_1, MdnsTextRecord.class)); + // Create a two identical responses. + MdnsResponse response = makeMdnsResponse(0, recordList); + MdnsResponse response2 = makeMdnsResponse(100, recordList); + // Merging should not indicate any change. + assertFalse(response.mergeRecordsFrom(response2)); + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java new file mode 100644 index 0000000000..5843fd0ff2 --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java @@ -0,0 +1,770 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.NonNull; + +import com.android.server.connectivity.mdns.MdnsServiceTypeClient.QueryTaskConfig; +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** Tests for {@link MdnsServiceTypeClient}. */ +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsServiceTypeClientTests { + + private static final String SERVICE_TYPE = "_googlecast._tcp.local"; + + @Mock + private MdnsServiceBrowserListener mockListenerOne; + @Mock + private MdnsServiceBrowserListener mockListenerTwo; + @Mock + private MdnsPacketWriter mockPacketWriter; + @Mock + private MdnsSocketClient mockSocketClient; + @Captor + private ArgumentCaptor serviceInfoCaptor; + + private final byte[] buf = new byte[10]; + + private DatagramPacket[] expectedPackets; + private ScheduledFuture[] expectedSendFutures; + private FakeExecutor currentThreadExecutor = new FakeExecutor(); + + private MdnsServiceTypeClient client; + + @Before + @SuppressWarnings("DoNotMock") + public void setUp() throws IOException { + MockitoAnnotations.initMocks(this); + + expectedPackets = new DatagramPacket[16]; + expectedSendFutures = new ScheduledFuture[16]; + + for (int i = 0; i < expectedSendFutures.length; ++i) { + expectedPackets[i] = new DatagramPacket(buf, 0, 5); + expectedSendFutures[i] = Mockito.mock(ScheduledFuture.class); + } + when(mockPacketWriter.getPacket(any(SocketAddress.class))) + .thenReturn(expectedPackets[0]) + .thenReturn(expectedPackets[1]) + .thenReturn(expectedPackets[2]) + .thenReturn(expectedPackets[3]) + .thenReturn(expectedPackets[4]) + .thenReturn(expectedPackets[5]) + .thenReturn(expectedPackets[6]) + .thenReturn(expectedPackets[7]) + .thenReturn(expectedPackets[8]) + .thenReturn(expectedPackets[9]) + .thenReturn(expectedPackets[10]) + .thenReturn(expectedPackets[11]) + .thenReturn(expectedPackets[12]) + .thenReturn(expectedPackets[13]) + .thenReturn(expectedPackets[14]) + .thenReturn(expectedPackets[15]); + + client = + new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor) { + @Override + MdnsPacketWriter createMdnsPacketWriter() { + return mockPacketWriter; + } + }; + } + + @Test + public void sendQueries_activeScanMode() { + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(false).build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + + // First burst, 3 queries. + verifyAndSendQuery(0, 0, /* expectsUnicastResponse= */ true); + verifyAndSendQuery( + 1, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 2, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + // Second burst will be sent after initialTimeBetweenBurstsMs, 3 queries. + verifyAndSendQuery( + 3, MdnsConfigs.initialTimeBetweenBurstsMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 4, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 5, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + // Third burst will be sent after initialTimeBetweenBurstsMs * 2, 3 queries. + verifyAndSendQuery( + 6, MdnsConfigs.initialTimeBetweenBurstsMs() * 2, /* expectsUnicastResponse= */ + false); + verifyAndSendQuery( + 7, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 8, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + // Forth burst will be sent after initialTimeBetweenBurstsMs * 4, 3 queries. + verifyAndSendQuery( + 9, MdnsConfigs.initialTimeBetweenBurstsMs() * 4, /* expectsUnicastResponse= */ + false); + verifyAndSendQuery( + 10, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 11, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + // Fifth burst will be sent after timeBetweenBurstsMs, 3 queries. + verifyAndSendQuery(12, MdnsConfigs.timeBetweenBurstsMs(), /* expectsUnicastResponse= */ + false); + verifyAndSendQuery( + 13, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 14, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + + // Stop sending packets. + client.stopSendAndReceive(mockListenerOne); + verify(expectedSendFutures[15]).cancel(true); + } + + @Test + public void sendQueries_reentry_activeScanMode() { + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(false).build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + + // First burst, first query is sent. + verifyAndSendQuery(0, 0, /* expectsUnicastResponse= */ true); + + // After the first query is sent, change the subtypes, and restart. + searchOptions = + MdnsSearchOptions.newBuilder() + .addSubtype("12345") + .addSubtype("abcde") + .setIsPassiveMode(false) + .build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + // The previous scheduled task should be canceled. + verify(expectedSendFutures[1]).cancel(true); + + // Queries should continue to be sent. + verifyAndSendQuery(1, 0, /* expectsUnicastResponse= */ true); + verifyAndSendQuery( + 2, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 3, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + + // Stop sending packets. + client.stopSendAndReceive(mockListenerOne); + verify(expectedSendFutures[5]).cancel(true); + } + + @Test + public void sendQueries_passiveScanMode() { + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(true).build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + + // First burst, 3 query. + verifyAndSendQuery(0, 0, /* expectsUnicastResponse= */ true); + verifyAndSendQuery( + 1, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 2, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + // Second burst will be sent after timeBetweenBurstsMs, 1 query. + verifyAndSendQuery(3, MdnsConfigs.timeBetweenBurstsMs(), /* expectsUnicastResponse= */ + false); + // Third burst will be sent after timeBetweenBurstsMs, 1 query. + verifyAndSendQuery(4, MdnsConfigs.timeBetweenBurstsMs(), /* expectsUnicastResponse= */ + false); + + // Stop sending packets. + client.stopSendAndReceive(mockListenerOne); + verify(expectedSendFutures[5]).cancel(true); + } + + @Test + public void sendQueries_reentry_passiveScanMode() { + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(true).build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + + // First burst, first query is sent. + verifyAndSendQuery(0, 0, /* expectsUnicastResponse= */ true); + + // After the first query is sent, change the subtypes, and restart. + searchOptions = + MdnsSearchOptions.newBuilder() + .addSubtype("12345") + .addSubtype("abcde") + .setIsPassiveMode(true) + .build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + // The previous scheduled task should be canceled. + verify(expectedSendFutures[1]).cancel(true); + + // Queries should continue to be sent. + verifyAndSendQuery(1, 0, /* expectsUnicastResponse= */ true); + verifyAndSendQuery( + 2, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + verifyAndSendQuery( + 3, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false); + + // Stop sending packets. + client.stopSendAndReceive(mockListenerOne); + verify(expectedSendFutures[5]).cancel(true); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void testQueryTaskConfig_alwaysAskForUnicastResponse() { + //MdnsConfigsFlagsImpl.alwaysAskForUnicastResponseInEachBurst.override(true); + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(false).build(); + QueryTaskConfig config = + new QueryTaskConfig(searchOptions.getSubtypes(), searchOptions.isPassiveMode(), 1); + + // This is the first query. We will ask for unicast response. + assertTrue(config.expectUnicastResponse); + assertEquals(config.subtypes, searchOptions.getSubtypes()); + assertEquals(config.transactionId, 1); + + // For the rest of queries in this burst, we will NOT ask for unicast response. + for (int i = 1; i < MdnsConfigs.queriesPerBurst(); i++) { + int oldTransactionId = config.transactionId; + config = config.getConfigForNextRun(); + assertFalse(config.expectUnicastResponse); + assertEquals(config.subtypes, searchOptions.getSubtypes()); + assertEquals(config.transactionId, oldTransactionId + 1); + } + + // This is the first query of a new burst. We will ask for unicast response. + int oldTransactionId = config.transactionId; + config = config.getConfigForNextRun(); + assertTrue(config.expectUnicastResponse); + assertEquals(config.subtypes, searchOptions.getSubtypes()); + assertEquals(config.transactionId, oldTransactionId + 1); + } + + @Test + public void testQueryTaskConfig_askForUnicastInFirstQuery() { + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(false).build(); + QueryTaskConfig config = + new QueryTaskConfig(searchOptions.getSubtypes(), searchOptions.isPassiveMode(), 1); + + // This is the first query. We will ask for unicast response. + assertTrue(config.expectUnicastResponse); + assertEquals(config.subtypes, searchOptions.getSubtypes()); + assertEquals(config.transactionId, 1); + + // For the rest of queries in this burst, we will NOT ask for unicast response. + for (int i = 1; i < MdnsConfigs.queriesPerBurst(); i++) { + int oldTransactionId = config.transactionId; + config = config.getConfigForNextRun(); + assertFalse(config.expectUnicastResponse); + assertEquals(config.subtypes, searchOptions.getSubtypes()); + assertEquals(config.transactionId, oldTransactionId + 1); + } + + // This is the first query of a new burst. We will NOT ask for unicast response. + int oldTransactionId = config.transactionId; + config = config.getConfigForNextRun(); + assertFalse(config.expectUnicastResponse); + assertEquals(config.subtypes, searchOptions.getSubtypes()); + assertEquals(config.transactionId, oldTransactionId + 1); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void testIfPreviousTaskIsCanceledWhenNewSessionStarts() { + //MdnsConfigsFlagsImpl.useSessionIdToScheduleMdnsTask.override(true); + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(true).build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable(); + + // Change the sutypes and start a new session. + searchOptions = + MdnsSearchOptions.newBuilder() + .addSubtype("12345") + .addSubtype("abcde") + .setIsPassiveMode(true) + .build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + + // Clear the scheduled runnable. + currentThreadExecutor.getAndClearLastScheduledRunnable(); + + // Simulate the case where the first mdns task is not successful canceled and it gets + // executed anyway. + firstMdnsTask.run(); + + // Although it gets executes, no more task gets scheduled. + assertNull(currentThreadExecutor.getAndClearLastScheduledRunnable()); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void testIfPreviousTaskIsCanceledWhenSessionStops() { + //MdnsConfigsFlagsImpl.shouldCancelScanTaskWhenFutureIsNull.override(true); + MdnsSearchOptions searchOptions = + MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(true).build(); + client.startSendAndReceive(mockListenerOne, searchOptions); + // Change the sutypes and start a new session. + client.stopSendAndReceive(mockListenerOne); + // Clear the scheduled runnable. + currentThreadExecutor.getAndClearLastScheduledRunnable(); + + // Simulate the case where the first mdns task is not successful canceled and it gets + // executed anyway. + currentThreadExecutor.getAndClearSubmittedRunnable().run(); + + // Although it gets executes, no more task gets scheduled. + assertNull(currentThreadExecutor.getAndClearLastScheduledRunnable()); + } + + @Test + public void processResponse_incompleteResponse() { + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + + MdnsResponse response = mock(MdnsResponse.class); + when(response.getServiceInstanceName()).thenReturn("service-instance-1"); + when(response.isComplete()).thenReturn(false); + + client.processResponse(response); + + verify(mockListenerOne, never()).onServiceFound(any(MdnsServiceInfo.class)); + verify(mockListenerOne, never()).onServiceUpdated(any(MdnsServiceInfo.class)); + } + + @Test + public void processIPv4Response_completeResponseForNewServiceInstance() throws Exception { + final String ipV4Address = "192.168.1.1"; + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + + // Process the initial response. + MdnsResponse initialResponse = + createResponse( + "service-instance-1", + ipV4Address, + 5353, + Collections.singletonList("ABCDE"), + Collections.emptyMap()); + client.processResponse(initialResponse); + + // Process a second response with a different port and updated text attributes. + MdnsResponse secondResponse = + createResponse( + "service-instance-1", + ipV4Address, + 5354, + Collections.singletonList("ABCDE"), + Collections.singletonMap("key", "value")); + client.processResponse(secondResponse); + + // Verify onServiceFound was called once for the initial response. + verify(mockListenerOne).onServiceFound(serviceInfoCaptor.capture()); + MdnsServiceInfo initialServiceInfo = serviceInfoCaptor.getAllValues().get(0); + assertEquals(initialServiceInfo.getServiceInstanceName(), "service-instance-1"); + assertEquals(initialServiceInfo.getIpv4Address(), ipV4Address); + assertEquals(initialServiceInfo.getPort(), 5353); + assertEquals(initialServiceInfo.getSubtypes(), Collections.singletonList("ABCDE")); + assertNull(initialServiceInfo.getAttributeByKey("key")); + + // Verify onServiceUpdated was called once for the second response. + verify(mockListenerOne).onServiceUpdated(serviceInfoCaptor.capture()); + MdnsServiceInfo updatedServiceInfo = serviceInfoCaptor.getAllValues().get(1); + assertEquals(updatedServiceInfo.getServiceInstanceName(), "service-instance-1"); + assertEquals(updatedServiceInfo.getIpv4Address(), ipV4Address); + assertEquals(updatedServiceInfo.getPort(), 5354); + assertTrue(updatedServiceInfo.hasSubtypes()); + assertEquals(updatedServiceInfo.getSubtypes(), Collections.singletonList("ABCDE")); + assertEquals(updatedServiceInfo.getAttributeByKey("key"), "value"); + } + + @Test + public void processIPv6Response_getCorrectServiceInfo() throws Exception { + final String ipV6Address = "2000:3333::da6c:63ff:fe7c:7483"; + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + + // Process the initial response. + MdnsResponse initialResponse = + createResponse( + "service-instance-1", + ipV6Address, + 5353, + Collections.singletonList("ABCDE"), + Collections.emptyMap()); + client.processResponse(initialResponse); + + // Process a second response with a different port and updated text attributes. + MdnsResponse secondResponse = + createResponse( + "service-instance-1", + ipV6Address, + 5354, + Collections.singletonList("ABCDE"), + Collections.singletonMap("key", "value")); + client.processResponse(secondResponse); + + System.out.println("secondResponses ip" + + secondResponse.getInet6AddressRecord().getInet6Address().getHostAddress()); + + // Verify onServiceFound was called once for the initial response. + verify(mockListenerOne).onServiceFound(serviceInfoCaptor.capture()); + MdnsServiceInfo initialServiceInfo = serviceInfoCaptor.getAllValues().get(0); + assertEquals(initialServiceInfo.getServiceInstanceName(), "service-instance-1"); + assertEquals(initialServiceInfo.getIpv6Address(), ipV6Address); + assertEquals(initialServiceInfo.getPort(), 5353); + assertEquals(initialServiceInfo.getSubtypes(), Collections.singletonList("ABCDE")); + assertNull(initialServiceInfo.getAttributeByKey("key")); + + // Verify onServiceUpdated was called once for the second response. + verify(mockListenerOne).onServiceUpdated(serviceInfoCaptor.capture()); + MdnsServiceInfo updatedServiceInfo = serviceInfoCaptor.getAllValues().get(1); + assertEquals(updatedServiceInfo.getServiceInstanceName(), "service-instance-1"); + assertEquals(updatedServiceInfo.getIpv6Address(), ipV6Address); + assertEquals(updatedServiceInfo.getPort(), 5354); + assertTrue(updatedServiceInfo.hasSubtypes()); + assertEquals(updatedServiceInfo.getSubtypes(), Collections.singletonList("ABCDE")); + assertEquals(updatedServiceInfo.getAttributeByKey("key"), "value"); + } + + @Test + public void processResponse_goodBye() { + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + client.startSendAndReceive(mockListenerTwo, MdnsSearchOptions.getDefaultOptions()); + + MdnsResponse response = mock(MdnsResponse.class); + when(response.getServiceInstanceName()).thenReturn("goodbye-service-instance-name"); + when(response.isGoodbye()).thenReturn(true); + client.processResponse(response); + + verify(mockListenerOne).onServiceRemoved("goodbye-service-instance-name"); + verify(mockListenerTwo).onServiceRemoved("goodbye-service-instance-name"); + } + + @Test + public void reportExistingServiceToNewlyRegisteredListeners() throws UnknownHostException { + // Process the initial response. + MdnsResponse initialResponse = + createResponse( + "service-instance-1", + "192.168.1.1", + 5353, + Collections.singletonList("ABCDE"), + Collections.emptyMap()); + client.processResponse(initialResponse); + + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + + // Verify onServiceFound was called once for the existing response. + verify(mockListenerOne).onServiceFound(serviceInfoCaptor.capture()); + MdnsServiceInfo existingServiceInfo = serviceInfoCaptor.getAllValues().get(0); + assertEquals(existingServiceInfo.getServiceInstanceName(), "service-instance-1"); + assertEquals(existingServiceInfo.getIpv4Address(), "192.168.1.1"); + assertEquals(existingServiceInfo.getPort(), 5353); + assertEquals(existingServiceInfo.getSubtypes(), Collections.singletonList("ABCDE")); + assertNull(existingServiceInfo.getAttributeByKey("key")); + + // Process a goodbye message for the existing response. + MdnsResponse goodByeResponse = mock(MdnsResponse.class); + when(goodByeResponse.getServiceInstanceName()).thenReturn("service-instance-1"); + when(goodByeResponse.isGoodbye()).thenReturn(true); + client.processResponse(goodByeResponse); + + client.startSendAndReceive(mockListenerTwo, MdnsSearchOptions.getDefaultOptions()); + + // Verify onServiceFound was not called on the newly registered listener after the existing + // response is gone. + verify(mockListenerTwo, never()).onServiceFound(any(MdnsServiceInfo.class)); + } + + @Test + public void processResponse_notAllowRemoveSearch_shouldNotRemove() throws Exception { + final String serviceInstanceName = "service-instance-1"; + client.startSendAndReceive( + mockListenerOne, + MdnsSearchOptions.newBuilder().build()); + Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable(); + + // Process the initial response. + MdnsResponse initialResponse = + createResponse( + serviceInstanceName, "192.168.1.1", 5353, List.of("ABCDE"), + Map.of()); + client.processResponse(initialResponse); + + // Clear the scheduled runnable. + currentThreadExecutor.getAndClearLastScheduledRunnable(); + + // Simulate the case where the response is after TTL. + when(initialResponse.getServiceRecord().getRemainingTTL(anyLong())).thenReturn((long) 0); + firstMdnsTask.run(); + + // Verify onServiceRemoved was not called. + verify(mockListenerOne, never()).onServiceRemoved(serviceInstanceName); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void processResponse_allowSearchOptionsToRemoveExpiredService_shouldRemove() + throws Exception { + //MdnsConfigsFlagsImpl.allowSearchOptionsToRemoveExpiredService.override(true); + final String serviceInstanceName = "service-instance-1"; + client = + new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor) { + @Override + MdnsPacketWriter createMdnsPacketWriter() { + return mockPacketWriter; + } + }; + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable(); + + // Process the initial response. + MdnsResponse initialResponse = + createResponse( + serviceInstanceName, "192.168.1.1", 5353, List.of("ABCDE"), + Map.of()); + client.processResponse(initialResponse); + + // Clear the scheduled runnable. + currentThreadExecutor.getAndClearLastScheduledRunnable(); + + // Simulate the case where the response is under TTL. + when(initialResponse.getServiceRecord().getRemainingTTL(anyLong())).thenReturn((long) 1000); + firstMdnsTask.run(); + + // Verify onServiceRemoved was not called. + verify(mockListenerOne, never()).onServiceRemoved(serviceInstanceName); + + // Simulate the case where the response is after TTL. + when(initialResponse.getServiceRecord().getRemainingTTL(anyLong())).thenReturn((long) 0); + firstMdnsTask.run(); + + // Verify onServiceRemoved was called. + verify(mockListenerOne, times(1)).onServiceRemoved(serviceInstanceName); + } + + @Test + public void processResponse_searchOptionsNotEnableServiceRemoval_shouldNotRemove() + throws Exception { + final String serviceInstanceName = "service-instance-1"; + client = + new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor) { + @Override + MdnsPacketWriter createMdnsPacketWriter() { + return mockPacketWriter; + } + }; + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable(); + + // Process the initial response. + MdnsResponse initialResponse = + createResponse( + serviceInstanceName, "192.168.1.1", 5353, List.of("ABCDE"), + Map.of()); + client.processResponse(initialResponse); + + // Clear the scheduled runnable. + currentThreadExecutor.getAndClearLastScheduledRunnable(); + + // Simulate the case where the response is after TTL. + when(initialResponse.getServiceRecord().getRemainingTTL(anyLong())).thenReturn((long) 0); + firstMdnsTask.run(); + + // Verify onServiceRemoved was not called. + verify(mockListenerOne, never()).onServiceRemoved(serviceInstanceName); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void processResponse_removeServiceAfterTtlExpiresEnabled_shouldRemove() + throws Exception { + //MdnsConfigsFlagsImpl.removeServiceAfterTtlExpires.override(true); + final String serviceInstanceName = "service-instance-1"; + client = + new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor) { + @Override + MdnsPacketWriter createMdnsPacketWriter() { + return mockPacketWriter; + } + }; + client.startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions()); + Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable(); + + // Process the initial response. + MdnsResponse initialResponse = + createResponse( + serviceInstanceName, "192.168.1.1", 5353, List.of("ABCDE"), + Map.of()); + client.processResponse(initialResponse); + + // Clear the scheduled runnable. + currentThreadExecutor.getAndClearLastScheduledRunnable(); + + // Simulate the case where the response is after TTL. + when(initialResponse.getServiceRecord().getRemainingTTL(anyLong())).thenReturn((long) 0); + firstMdnsTask.run(); + + // Verify onServiceRemoved was not called. + verify(mockListenerOne, times(1)).onServiceRemoved(serviceInstanceName); + } + + // verifies that the right query was enqueued with the right delay, and send query by executing + // the runnable. + private void verifyAndSendQuery(int index, long timeInMs, boolean expectsUnicastResponse) { + assertEquals(currentThreadExecutor.getAndClearLastScheduledDelayInMs(), timeInMs); + currentThreadExecutor.getAndClearLastScheduledRunnable().run(); + if (expectsUnicastResponse) { + verify(mockSocketClient).sendUnicastPacket(expectedPackets[index]); + } else { + verify(mockSocketClient).sendMulticastPacket(expectedPackets[index]); + } + } + + // A fake ScheduledExecutorService that keeps tracking the last scheduled Runnable and its delay + // time. + private class FakeExecutor extends ScheduledThreadPoolExecutor { + private long lastScheduledDelayInMs; + private Runnable lastScheduledRunnable; + private Runnable lastSubmittedRunnable; + private int futureIndex; + + FakeExecutor() { + super(1); + lastScheduledDelayInMs = -1; + } + + @Override + public Future submit(Runnable command) { + Future future = super.submit(command); + lastSubmittedRunnable = command; + return future; + } + + // Don't call through the real implementation, just track the scheduled Runnable, and + // returns a ScheduledFuture. + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + lastScheduledDelayInMs = delay; + lastScheduledRunnable = command; + return expectedSendFutures[futureIndex++]; + } + + // Returns the delay of the last scheduled task, and clear it. + long getAndClearLastScheduledDelayInMs() { + long val = lastScheduledDelayInMs; + lastScheduledDelayInMs = -1; + return val; + } + + // Returns the last scheduled task, and clear it. + Runnable getAndClearLastScheduledRunnable() { + Runnable val = lastScheduledRunnable; + lastScheduledRunnable = null; + return val; + } + + Runnable getAndClearSubmittedRunnable() { + Runnable val = lastSubmittedRunnable; + lastSubmittedRunnable = null; + return val; + } + } + + // Creates a complete mDNS response. + private MdnsResponse createResponse( + @NonNull String serviceInstanceName, + @NonNull String host, + int port, + @NonNull List subtypes, + @NonNull Map textAttributes) + throws UnknownHostException { + String[] hostName = new String[]{"hostname"}; + MdnsServiceRecord serviceRecord = mock(MdnsServiceRecord.class); + when(serviceRecord.getServiceHost()).thenReturn(hostName); + when(serviceRecord.getServicePort()).thenReturn(port); + + MdnsResponse response = spy(new MdnsResponse(0)); + + MdnsInetAddressRecord inetAddressRecord = mock(MdnsInetAddressRecord.class); + if (host.contains(":")) { + when(inetAddressRecord.getInet6Address()) + .thenReturn((Inet6Address) Inet6Address.getByName(host)); + response.setInet6AddressRecord(inetAddressRecord); + } else { + when(inetAddressRecord.getInet4Address()) + .thenReturn((Inet4Address) Inet4Address.getByName(host)); + response.setInet4AddressRecord(inetAddressRecord); + } + + MdnsTextRecord textRecord = mock(MdnsTextRecord.class); + List textStrings = new ArrayList<>(); + for (Map.Entry kv : textAttributes.entrySet()) { + textStrings.add(kv.getKey() + "=" + kv.getValue()); + } + when(textRecord.getStrings()).thenReturn(textStrings); + + response.setServiceRecord(serviceRecord); + response.setTextRecord(textRecord); + + doReturn(false).when(response).isGoodbye(); + doReturn(true).when(response).isComplete(); + doReturn(serviceInstanceName).when(response).getServiceInstanceName(); + doReturn(new ArrayList<>(subtypes)).when(response).getSubtypes(); + return response; + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java new file mode 100644 index 0000000000..21ed7eb96e --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java @@ -0,0 +1,493 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.Manifest.permission; +import android.annotation.RequiresPermission; +import android.content.Context; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiManager.MulticastLock; +import android.text.format.DateUtils; + +import com.android.net.module.util.HexDump; +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Tests for {@link MdnsSocketClient} */ +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsSocketClientTests { + private static final long TIMEOUT = 500; + private final byte[] buf = new byte[10]; + final AtomicBoolean enableMulticastResponse = new AtomicBoolean(true); + final AtomicBoolean enableUnicastResponse = new AtomicBoolean(true); + + @Mock private Context mContext; + @Mock private WifiManager mockWifiManager; + @Mock private MdnsSocket mockMulticastSocket; + @Mock private MdnsSocket mockUnicastSocket; + @Mock private MulticastLock mockMulticastLock; + @Mock private MdnsSocketClient.Callback mockCallback; + + private MdnsSocketClient mdnsClient; + + @Before + public void setup() throws RuntimeException, IOException { + MockitoAnnotations.initMocks(this); + + when(mockWifiManager.createMulticastLock(ArgumentMatchers.anyString())) + .thenReturn(mockMulticastLock); + + mdnsClient = new MdnsSocketClient(mContext, mockMulticastLock) { + @Override + MdnsSocket createMdnsSocket(int port) throws IOException { + if (port == MdnsConstants.MDNS_PORT) { + return mockMulticastSocket; + } + return mockUnicastSocket; + } + }; + mdnsClient.setCallback(mockCallback); + + doAnswer( + (InvocationOnMock invocationOnMock) -> { + final byte[] dataIn = HexDump.hexStringToByteArray( + "0000840000000004" + + "00000003134A6F68" + + "6E6E792773204368" + + "726F6D6563617374" + + "0B5F676F6F676C65" + + "63617374045F7463" + + "70056C6F63616C00" + + "0010800100001194" + + "006C2369643D3937" + + "3062663534376237" + + "3533666336336332" + + "6432613336626238" + + "3936616261380576" + + "653D30320D6D643D" + + "4368726F6D656361" + + "73741269633D2F73" + + "657475702F69636F" + + "6E2E706E6716666E" + + "3D4A6F686E6E7927" + + "73204368726F6D65" + + "636173740463613D" + + "350473743D30095F" + + "7365727669636573" + + "075F646E732D7364" + + "045F756470C03100" + + "0C00010000119400" + + "02C020C020000C00" + + "01000011940002C0" + + "0CC00C0021800100" + + "000078001C000000" + + "001F49134A6F686E" + + "6E79277320436872" + + "6F6D6563617374C0" + + "31C0F30001800100" + + "0000780004C0A864" + + "68C0F3002F800100" + + "0000780005C0F300" + + "0140C00C002F8001" + + "000011940009C00C" + + "00050000800040"); + if (enableMulticastResponse.get()) { + DatagramPacket packet = invocationOnMock.getArgument(0); + packet.setData(dataIn); + } + return null; + }) + .when(mockMulticastSocket) + .receive(any(DatagramPacket.class)); + doAnswer( + (InvocationOnMock invocationOnMock) -> { + final byte[] dataIn = HexDump.hexStringToByteArray( + "0000840000000004" + + "00000003134A6F68" + + "6E6E792773204368" + + "726F6D6563617374" + + "0B5F676F6F676C65" + + "63617374045F7463" + + "70056C6F63616C00" + + "0010800100001194" + + "006C2369643D3937" + + "3062663534376237" + + "3533666336336332" + + "6432613336626238" + + "3936616261380576" + + "653D30320D6D643D" + + "4368726F6D656361" + + "73741269633D2F73" + + "657475702F69636F" + + "6E2E706E6716666E" + + "3D4A6F686E6E7927" + + "73204368726F6D65" + + "636173740463613D" + + "350473743D30095F" + + "7365727669636573" + + "075F646E732D7364" + + "045F756470C03100" + + "0C00010000119400" + + "02C020C020000C00" + + "01000011940002C0" + + "0CC00C0021800100" + + "000078001C000000" + + "001F49134A6F686E" + + "6E79277320436872" + + "6F6D6563617374C0" + + "31C0F30001800100" + + "0000780004C0A864" + + "68C0F3002F800100" + + "0000780005C0F300" + + "0140C00C002F8001" + + "000011940009C00C" + + "00050000800040"); + if (enableUnicastResponse.get()) { + DatagramPacket packet = invocationOnMock.getArgument(0); + packet.setData(dataIn); + } + return null; + }) + .when(mockUnicastSocket) + .receive(any(DatagramPacket.class)); + } + + @After + public void tearDown() { + mdnsClient.stopDiscovery(); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void testSendPackets_useSeparateSocketForUnicast() + throws InterruptedException, IOException { + //MdnsConfigsFlagsImpl.useSeparateSocketToSendUnicastQuery.override(true); + //MdnsConfigsFlagsImpl.checkMulticastResponse.override(true); + //MdnsConfigsFlagsImpl.checkMulticastResponseIntervalMs + // .override(DateUtils.SECOND_IN_MILLIS); + mdnsClient.startDiscovery(); + Thread multicastReceiverThread = mdnsClient.multicastReceiveThread; + Thread unicastReceiverThread = mdnsClient.unicastReceiveThread; + Thread sendThread = mdnsClient.sendThread; + + assertTrue(multicastReceiverThread.isAlive()); + assertTrue(sendThread.isAlive()); + assertTrue(unicastReceiverThread.isAlive()); + + // Sends a packet. + DatagramPacket packet = new DatagramPacket(buf, 0, 5); + mdnsClient.sendMulticastPacket(packet); + // mockMulticastSocket.send() will be called on another thread. If we verify it immediately, + // it may not be called yet. So timeout is added. + verify(mockMulticastSocket, timeout(TIMEOUT).times(1)).send(packet); + verify(mockUnicastSocket, timeout(TIMEOUT).times(0)).send(packet); + + // Verify the packet is sent by the unicast socket. + mdnsClient.sendUnicastPacket(packet); + verify(mockMulticastSocket, timeout(TIMEOUT).times(1)).send(packet); + verify(mockUnicastSocket, timeout(TIMEOUT).times(1)).send(packet); + + // Stop the MdnsClient, and ensure that it stops in a reasonable amount of time. + // Run part of the test logic in a background thread, in case stopDiscovery() blocks + // for a long time (the foreground thread can fail the test early). + final CountDownLatch stopDiscoveryLatch = new CountDownLatch(1); + Thread testThread = + new Thread( + new Runnable() { + @RequiresPermission(permission.CHANGE_WIFI_MULTICAST_STATE) + @Override + public void run() { + mdnsClient.stopDiscovery(); + stopDiscoveryLatch.countDown(); + } + }); + testThread.start(); + assertTrue(stopDiscoveryLatch.await(DateUtils.SECOND_IN_MILLIS, TimeUnit.MILLISECONDS)); + + // We should be able to join in a reasonable amount of time, to prove that the + // the MdnsClient exited without sending the large queue of packets. + testThread.join(DateUtils.SECOND_IN_MILLIS); + + assertFalse(multicastReceiverThread.isAlive()); + assertFalse(sendThread.isAlive()); + assertFalse(unicastReceiverThread.isAlive()); + } + + @Test + public void testSendPackets_useSameSocketForMulticastAndUnicast() + throws InterruptedException, IOException { + mdnsClient.startDiscovery(); + Thread multicastReceiverThread = mdnsClient.multicastReceiveThread; + Thread unicastReceiverThread = mdnsClient.unicastReceiveThread; + Thread sendThread = mdnsClient.sendThread; + + assertTrue(multicastReceiverThread.isAlive()); + assertTrue(sendThread.isAlive()); + assertNull(unicastReceiverThread); + + // Sends a packet. + DatagramPacket packet = new DatagramPacket(buf, 0, 5); + mdnsClient.sendMulticastPacket(packet); + // mockMulticastSocket.send() will be called on another thread. If we verify it immediately, + // it may not be called yet. So timeout is added. + verify(mockMulticastSocket, timeout(TIMEOUT).times(1)).send(packet); + verify(mockUnicastSocket, timeout(TIMEOUT).times(0)).send(packet); + + // Verify the packet is sent by the multicast socket as well. + mdnsClient.sendUnicastPacket(packet); + verify(mockMulticastSocket, timeout(TIMEOUT).times(2)).send(packet); + verify(mockUnicastSocket, timeout(TIMEOUT).times(0)).send(packet); + + // Stop the MdnsClient, and ensure that it stops in a reasonable amount of time. + // Run part of the test logic in a background thread, in case stopDiscovery() blocks + // for a long time (the foreground thread can fail the test early). + final CountDownLatch stopDiscoveryLatch = new CountDownLatch(1); + Thread testThread = + new Thread( + new Runnable() { + @RequiresPermission(permission.CHANGE_WIFI_MULTICAST_STATE) + @Override + public void run() { + mdnsClient.stopDiscovery(); + stopDiscoveryLatch.countDown(); + } + }); + testThread.start(); + assertTrue(stopDiscoveryLatch.await(DateUtils.SECOND_IN_MILLIS, TimeUnit.MILLISECONDS)); + + // We should be able to join in a reasonable amount of time, to prove that the + // the MdnsClient exited without sending the large queue of packets. + testThread.join(DateUtils.SECOND_IN_MILLIS); + + assertFalse(multicastReceiverThread.isAlive()); + assertFalse(sendThread.isAlive()); + assertNull(unicastReceiverThread); + } + + @Test + public void testStartStop() throws IOException { + for (int i = 0; i < 5; i++) { + mdnsClient.startDiscovery(); + + Thread multicastReceiverThread = mdnsClient.multicastReceiveThread; + Thread socketThread = mdnsClient.sendThread; + + assertTrue(multicastReceiverThread.isAlive()); + assertTrue(socketThread.isAlive()); + + mdnsClient.stopDiscovery(); + + assertFalse(multicastReceiverThread.isAlive()); + assertFalse(socketThread.isAlive()); + } + } + + @Test + public void testStopDiscovery_queueIsCleared() throws IOException { + mdnsClient.startDiscovery(); + mdnsClient.stopDiscovery(); + mdnsClient.sendMulticastPacket(new DatagramPacket(buf, 0, 5)); + + synchronized (mdnsClient.multicastPacketQueue) { + assertTrue(mdnsClient.multicastPacketQueue.isEmpty()); + } + } + + @Test + public void testSendPacket_afterDiscoveryStops() throws IOException { + mdnsClient.startDiscovery(); + mdnsClient.stopDiscovery(); + mdnsClient.sendMulticastPacket(new DatagramPacket(buf, 0, 5)); + + synchronized (mdnsClient.multicastPacketQueue) { + assertTrue(mdnsClient.multicastPacketQueue.isEmpty()); + } + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void testSendPacket_queueReachesSizeLimit() throws IOException { + //MdnsConfigsFlagsImpl.mdnsPacketQueueMaxSize.override(2L); + mdnsClient.startDiscovery(); + for (int i = 0; i < 100; i++) { + mdnsClient.sendMulticastPacket(new DatagramPacket(buf, 0, 5)); + } + + synchronized (mdnsClient.multicastPacketQueue) { + assertTrue(mdnsClient.multicastPacketQueue.size() <= 2); + } + } + + @Test + public void testMulticastResponseReceived_useSeparateSocketForUnicast() throws IOException { + mdnsClient.setCallback(mockCallback); + + mdnsClient.startDiscovery(); + + verify(mockCallback, timeout(TIMEOUT).atLeast(1)) + .onResponseReceived(any(MdnsResponse.class)); + } + + @Test + public void testMulticastResponseReceived_useSameSocketForMulticastAndUnicast() + throws Exception { + mdnsClient.startDiscovery(); + + verify(mockCallback, timeout(TIMEOUT).atLeastOnce()) + .onResponseReceived(any(MdnsResponse.class)); + + mdnsClient.stopDiscovery(); + } + + @Test + public void testFailedToParseMdnsResponse_useSeparateSocketForUnicast() throws IOException { + mdnsClient.setCallback(mockCallback); + + // Both multicast socket and unicast socket receive malformed responses. + byte[] dataIn = HexDump.hexStringToByteArray("0000840000000004"); + doAnswer( + (InvocationOnMock invocationOnMock) -> { + // Malformed data. + DatagramPacket packet = invocationOnMock.getArgument(0); + packet.setData(dataIn); + return null; + }) + .when(mockMulticastSocket) + .receive(any(DatagramPacket.class)); + doAnswer( + (InvocationOnMock invocationOnMock) -> { + // Malformed data. + DatagramPacket packet = invocationOnMock.getArgument(0); + packet.setData(dataIn); + return null; + }) + .when(mockUnicastSocket) + .receive(any(DatagramPacket.class)); + + mdnsClient.startDiscovery(); + + verify(mockCallback, timeout(TIMEOUT).atLeast(1)) + .onFailedToParseMdnsResponse(anyInt(), eq(MdnsResponseErrorCode.ERROR_END_OF_FILE)); + + mdnsClient.stopDiscovery(); + } + + @Test + public void testFailedToParseMdnsResponse_useSameSocketForMulticastAndUnicast() + throws IOException { + doAnswer( + (InvocationOnMock invocationOnMock) -> { + final byte[] dataIn = HexDump.hexStringToByteArray("0000840000000004"); + DatagramPacket packet = invocationOnMock.getArgument(0); + packet.setData(dataIn); + return null; + }) + .when(mockMulticastSocket) + .receive(any(DatagramPacket.class)); + + mdnsClient.startDiscovery(); + + verify(mockCallback, timeout(TIMEOUT).atLeast(1)) + .onFailedToParseMdnsResponse(1, MdnsResponseErrorCode.ERROR_END_OF_FILE); + + mdnsClient.stopDiscovery(); + } + + @Test + @Ignore("MdnsConfigs is not configurable currently.") + public void testMulticastResponseIsNotReceived() throws IOException, InterruptedException { + //MdnsConfigsFlagsImpl.checkMulticastResponse.override(true); + //MdnsConfigsFlagsImpl.checkMulticastResponseIntervalMs + // .override(DateUtils.SECOND_IN_MILLIS); + //MdnsConfigsFlagsImpl.useSeparateSocketToSendUnicastQuery.override(true); + enableMulticastResponse.set(false); + enableUnicastResponse.set(true); + + mdnsClient.startDiscovery(); + DatagramPacket packet = new DatagramPacket(buf, 0, 5); + mdnsClient.sendUnicastPacket(packet); + mdnsClient.sendMulticastPacket(packet); + + // Wait for the timer to be triggered. + Thread.sleep(MdnsConfigs.checkMulticastResponseIntervalMs() * 2); + + assertFalse(mdnsClient.receivedMulticastResponse); + assertTrue(mdnsClient.receivedUnicastResponse); + assertTrue(mdnsClient.cannotReceiveMulticastResponse.get()); + + // Allow multicast response and verify the states again. + enableMulticastResponse.set(true); + Thread.sleep(DateUtils.SECOND_IN_MILLIS); + + // Verify cannotReceiveMulticastResponse is reset to false. + assertTrue(mdnsClient.receivedMulticastResponse); + assertTrue(mdnsClient.receivedUnicastResponse); + assertFalse(mdnsClient.cannotReceiveMulticastResponse.get()); + + // Stop the discovery and start a new session. Don't respond the unicsat query either in + // this session. + enableMulticastResponse.set(false); + enableUnicastResponse.set(false); + mdnsClient.stopDiscovery(); + mdnsClient.startDiscovery(); + + // Verify the states are reset. + assertFalse(mdnsClient.receivedMulticastResponse); + assertFalse(mdnsClient.receivedUnicastResponse); + assertFalse(mdnsClient.cannotReceiveMulticastResponse.get()); + + mdnsClient.sendUnicastPacket(packet); + mdnsClient.sendMulticastPacket(packet); + Thread.sleep(MdnsConfigs.checkMulticastResponseIntervalMs() * 2); + + // Verify cannotReceiveMulticastResponse is not set the true because we didn't receive the + // unicast response either. This is expected for users who don't have any cast device. + assertFalse(mdnsClient.receivedMulticastResponse); + assertFalse(mdnsClient.receivedUnicastResponse); + assertFalse(mdnsClient.cannotReceiveMulticastResponse.get()); + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketTests.java new file mode 100644 index 0000000000..9f11a4bf49 --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketTests.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.net.DatagramPacket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.MulticastSocket; +import java.net.NetworkInterface; +import java.net.SocketAddress; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.Collections; + +/** Tests for {@link MdnsSocket}. */ +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MdnsSocketTests { + + @Mock private NetworkInterfaceWrapper mockNetworkInterfaceWrapper; + @Mock private MulticastSocket mockMulticastSocket; + @Mock private MulticastNetworkInterfaceProvider mockMulticastNetworkInterfaceProvider; + private SocketAddress socketIPv4Address; + private SocketAddress socketIPv6Address; + + private byte[] data = new byte[25]; + private final DatagramPacket datagramPacket = new DatagramPacket(data, data.length); + private NetworkInterface networkInterface; + + private MdnsSocket mdnsSocket; + + @Before + public void setUp() throws SocketException, UnknownHostException { + MockitoAnnotations.initMocks(this); + + networkInterface = createEmptyNetworkInterface(); + when(mockNetworkInterfaceWrapper.getNetworkInterface()).thenReturn(networkInterface); + when(mockMulticastNetworkInterfaceProvider.getMulticastNetworkInterfaces()) + .thenReturn(Collections.singletonList(mockNetworkInterfaceWrapper)); + socketIPv4Address = new InetSocketAddress( + InetAddress.getByName("224.0.0.251"), MdnsConstants.MDNS_PORT); + socketIPv6Address = new InetSocketAddress( + InetAddress.getByName("FF02::FB"), MdnsConstants.MDNS_PORT); + } + + @Test + public void testMdnsSocket() throws IOException { + mdnsSocket = + new MdnsSocket(mockMulticastNetworkInterfaceProvider, MdnsConstants.MDNS_PORT) { + @Override + MulticastSocket createMulticastSocket(int port) throws IOException { + return mockMulticastSocket; + } + }; + mdnsSocket.send(datagramPacket); + verify(mockMulticastSocket).setNetworkInterface(networkInterface); + verify(mockMulticastSocket).send(datagramPacket); + + mdnsSocket.receive(datagramPacket); + verify(mockMulticastSocket).receive(datagramPacket); + + mdnsSocket.joinGroup(); + verify(mockMulticastSocket).joinGroup(socketIPv4Address, networkInterface); + + mdnsSocket.leaveGroup(); + verify(mockMulticastSocket).leaveGroup(socketIPv4Address, networkInterface); + + mdnsSocket.close(); + verify(mockMulticastSocket).close(); + } + + @Test + public void testIPv6OnlyNetwork_IPv6Enabled() throws IOException { + // Have mockMulticastNetworkInterfaceProvider send back an IPv6Only networkInterfaceWrapper + networkInterface = createEmptyNetworkInterface(); + when(mockNetworkInterfaceWrapper.getNetworkInterface()).thenReturn(networkInterface); + when(mockMulticastNetworkInterfaceProvider.getMulticastNetworkInterfaces()) + .thenReturn(Collections.singletonList(mockNetworkInterfaceWrapper)); + + mdnsSocket = + new MdnsSocket(mockMulticastNetworkInterfaceProvider, MdnsConstants.MDNS_PORT) { + @Override + MulticastSocket createMulticastSocket(int port) throws IOException { + return mockMulticastSocket; + } + }; + + when(mockMulticastNetworkInterfaceProvider.isOnIpV6OnlyNetwork( + Collections.singletonList(mockNetworkInterfaceWrapper))) + .thenReturn(true); + + mdnsSocket.joinGroup(); + verify(mockMulticastSocket).joinGroup(socketIPv6Address, networkInterface); + + mdnsSocket.leaveGroup(); + verify(mockMulticastSocket).leaveGroup(socketIPv6Address, networkInterface); + + mdnsSocket.close(); + verify(mockMulticastSocket).close(); + } + + @Test + public void testIPv6OnlyNetwork_IPv6Toggle() throws IOException { + // Have mockMulticastNetworkInterfaceProvider send back a networkInterfaceWrapper + networkInterface = createEmptyNetworkInterface(); + when(mockNetworkInterfaceWrapper.getNetworkInterface()).thenReturn(networkInterface); + when(mockMulticastNetworkInterfaceProvider.getMulticastNetworkInterfaces()) + .thenReturn(Collections.singletonList(mockNetworkInterfaceWrapper)); + + mdnsSocket = + new MdnsSocket(mockMulticastNetworkInterfaceProvider, MdnsConstants.MDNS_PORT) { + @Override + MulticastSocket createMulticastSocket(int port) throws IOException { + return mockMulticastSocket; + } + }; + + when(mockMulticastNetworkInterfaceProvider.isOnIpV6OnlyNetwork( + Collections.singletonList(mockNetworkInterfaceWrapper))) + .thenReturn(true); + + mdnsSocket.joinGroup(); + verify(mockMulticastSocket).joinGroup(socketIPv6Address, networkInterface); + + mdnsSocket.leaveGroup(); + verify(mockMulticastSocket).leaveGroup(socketIPv6Address, networkInterface); + + mdnsSocket.close(); + verify(mockMulticastSocket).close(); + } + + private NetworkInterface createEmptyNetworkInterface() { + try { + Constructor constructor = + NetworkInterface.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MulticastNetworkInterfaceProviderTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MulticastNetworkInterfaceProviderTests.java new file mode 100644 index 0000000000..2268dfe091 --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/mdns/MulticastNetworkInterfaceProviderTests.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.server.connectivity.mdns; + +import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.annotation.NonNull; +import android.content.Context; + +import androidx.test.InstrumentationRegistry; + +import com.android.testutils.DevSdkIgnoreRule; +import com.android.testutils.DevSdkIgnoreRunner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Tests for {@link MulticastNetworkInterfaceProvider}. */ +@RunWith(DevSdkIgnoreRunner.class) +@DevSdkIgnoreRule.IgnoreUpTo(SC_V2) +public class MulticastNetworkInterfaceProviderTests { + + @Mock private NetworkInterfaceWrapper loopbackInterface; + @Mock private NetworkInterfaceWrapper pointToPointInterface; + @Mock private NetworkInterfaceWrapper virtualInterface; + @Mock private NetworkInterfaceWrapper inactiveMulticastInterface; + @Mock private NetworkInterfaceWrapper activeIpv6MulticastInterface; + @Mock private NetworkInterfaceWrapper activeIpv6MulticastInterfaceTwo; + @Mock private NetworkInterfaceWrapper nonMulticastInterface; + @Mock private NetworkInterfaceWrapper multicastInterfaceOne; + @Mock private NetworkInterfaceWrapper multicastInterfaceTwo; + + private final List networkInterfaces = new ArrayList<>(); + private MulticastNetworkInterfaceProvider provider; + private Context context; + + @Before + public void setUp() throws SocketException, UnknownHostException { + MockitoAnnotations.initMocks(this); + context = InstrumentationRegistry.getContext(); + + setupNetworkInterface( + loopbackInterface, + true /* isUp */, + true /* isLoopBack */, + false /* isPointToPoint */, + false /* isVirtual */, + true /* supportsMulticast */, + false /* isIpv6 */); + + setupNetworkInterface( + pointToPointInterface, + true /* isUp */, + false /* isLoopBack */, + true /* isPointToPoint */, + false /* isVirtual */, + true /* supportsMulticast */, + false /* isIpv6 */); + + setupNetworkInterface( + virtualInterface, + true /* isUp */, + false /* isLoopBack */, + false /* isPointToPoint */, + true /* isVirtual */, + true /* supportsMulticast */, + false /* isIpv6 */); + + setupNetworkInterface( + inactiveMulticastInterface, + false /* isUp */, + false /* isLoopBack */, + false /* isPointToPoint */, + false /* isVirtual */, + true /* supportsMulticast */, + false /* isIpv6 */); + + setupNetworkInterface( + nonMulticastInterface, + true /* isUp */, + false /* isLoopBack */, + false /* isPointToPoint */, + false /* isVirtual */, + false /* supportsMulticast */, + false /* isIpv6 */); + + setupNetworkInterface( + activeIpv6MulticastInterface, + true /* isUp */, + false /* isLoopBack */, + false /* isPointToPoint */, + false /* isVirtual */, + true /* supportsMulticast */, + true /* isIpv6 */); + + setupNetworkInterface( + activeIpv6MulticastInterfaceTwo, + true /* isUp */, + false /* isLoopBack */, + false /* isPointToPoint */, + false /* isVirtual */, + true /* supportsMulticast */, + true /* isIpv6 */); + + setupNetworkInterface( + multicastInterfaceOne, + true /* isUp */, + false /* isLoopBack */, + false /* isPointToPoint */, + false /* isVirtual */, + true /* supportsMulticast */, + false /* isIpv6 */); + + setupNetworkInterface( + multicastInterfaceTwo, + true /* isUp */, + false /* isLoopBack */, + false /* isPointToPoint */, + false /* isVirtual */, + true /* supportsMulticast */, + false /* isIpv6 */); + + provider = + new MulticastNetworkInterfaceProvider(context) { + @Override + List getNetworkInterfaces() { + return networkInterfaces; + } + }; + } + + @Test + public void testGetMulticastNetworkInterfaces() { + // getNetworkInterfaces returns 1 multicast interface and 5 interfaces that can not be used + // to send and receive multicast packets. + networkInterfaces.add(loopbackInterface); + networkInterfaces.add(pointToPointInterface); + networkInterfaces.add(virtualInterface); + networkInterfaces.add(inactiveMulticastInterface); + networkInterfaces.add(nonMulticastInterface); + networkInterfaces.add(multicastInterfaceOne); + + assertEquals(Collections.singletonList(multicastInterfaceOne), + provider.getMulticastNetworkInterfaces()); + + // getNetworkInterfaces returns 2 multicast interfaces after a connectivity change. + networkInterfaces.clear(); + networkInterfaces.add(multicastInterfaceOne); + networkInterfaces.add(multicastInterfaceTwo); + + provider.connectivityMonitor.notifyConnectivityChange(); + + assertEquals(networkInterfaces, provider.getMulticastNetworkInterfaces()); + } + + @Test + public void testStartWatchingConnectivityChanges() { + ConnectivityMonitor mockMonitor = mock(ConnectivityMonitor.class); + provider.connectivityMonitor = mockMonitor; + + InOrder inOrder = inOrder(mockMonitor); + + provider.startWatchingConnectivityChanges(); + inOrder.verify(mockMonitor).startWatchingConnectivityChanges(); + + provider.stopWatchingConnectivityChanges(); + inOrder.verify(mockMonitor).stopWatchingConnectivityChanges(); + } + + @Test + public void testIpV6OnlyNetwork_EmptyNetwork() { + // getNetworkInterfaces returns no network interfaces. + assertFalse(provider.isOnIpV6OnlyNetwork(networkInterfaces)); + } + + @Test + public void testIpV6OnlyNetwork_IPv4Only() { + // getNetworkInterfaces returns two IPv4 network interface. + networkInterfaces.add(multicastInterfaceOne); + networkInterfaces.add(multicastInterfaceTwo); + assertFalse(provider.isOnIpV6OnlyNetwork(networkInterfaces)); + } + + @Test + public void testIpV6OnlyNetwork_MixedNetwork() { + // getNetworkInterfaces returns one IPv6 network interface. + networkInterfaces.add(activeIpv6MulticastInterface); + networkInterfaces.add(multicastInterfaceOne); + networkInterfaces.add(activeIpv6MulticastInterfaceTwo); + networkInterfaces.add(multicastInterfaceTwo); + assertFalse(provider.isOnIpV6OnlyNetwork(networkInterfaces)); + } + + @Test + public void testIpV6OnlyNetwork_IPv6Only() { + // getNetworkInterfaces returns one IPv6 network interface. + networkInterfaces.add(activeIpv6MulticastInterface); + networkInterfaces.add(activeIpv6MulticastInterfaceTwo); + assertTrue(provider.isOnIpV6OnlyNetwork(networkInterfaces)); + } + + @Test + public void testIpV6OnlyNetwork_IPv6Enabled() { + // getNetworkInterfaces returns one IPv6 network interface. + networkInterfaces.add(activeIpv6MulticastInterface); + assertTrue(provider.isOnIpV6OnlyNetwork(networkInterfaces)); + + final List interfaces = provider.getMulticastNetworkInterfaces(); + assertEquals(Collections.singletonList(activeIpv6MulticastInterface), interfaces); + } + + private void setupNetworkInterface( + @NonNull NetworkInterfaceWrapper networkInterfaceWrapper, + boolean isUp, + boolean isLoopback, + boolean isPointToPoint, + boolean isVirtual, + boolean supportsMulticast, + boolean isIpv6) + throws SocketException, UnknownHostException { + when(networkInterfaceWrapper.isUp()).thenReturn(isUp); + when(networkInterfaceWrapper.isLoopback()).thenReturn(isLoopback); + when(networkInterfaceWrapper.isPointToPoint()).thenReturn(isPointToPoint); + when(networkInterfaceWrapper.isVirtual()).thenReturn(isVirtual); + when(networkInterfaceWrapper.supportsMulticast()).thenReturn(supportsMulticast); + if (isIpv6) { + InterfaceAddress interfaceAddress = mock(InterfaceAddress.class); + InetAddress ip6Address = Inet6Address.getByName("2001:4860:0:1001::68"); + when(interfaceAddress.getAddress()).thenReturn(ip6Address); + when(networkInterfaceWrapper.getInterfaceAddresses()) + .thenReturn(Collections.singletonList(interfaceAddress)); + } else { + Inet4Address ip = (Inet4Address) Inet4Address.getByName("192.168.0.1"); + InterfaceAddress interfaceAddress = mock(InterfaceAddress.class); + when(interfaceAddress.getAddress()).thenReturn(ip); + when(networkInterfaceWrapper.getInterfaceAddresses()) + .thenReturn(Collections.singletonList(interfaceAddress)); + } + } +} \ No newline at end of file