From 87c688df4be74139490c6c9987b8ba06c2e753b0 Mon Sep 17 00:00:00 2001 From: Handa Wang Date: Thu, 1 Feb 2024 06:43:27 +0000 Subject: [PATCH] [mdns] support regsitering hosts in NsdPublisher This CL also adds an integration test case for the Advertising Proxy feature. Bug: 318323473 Test: atest ThreadNetworkIntegrationTests:android.net.thread.ServiceDiscoveryTest Change-Id: I626aa6250bc2cb94aee09067b4f8e6b14bd90fcf --- .../android/server/thread/NsdPublisher.java | 26 ++ thread/tests/integration/Android.bp | 1 + .../net/thread/ServiceDiscoveryTest.java | 351 ++++++++++++++++++ .../net/thread/utils/FullThreadDevice.java | 148 +++++++- .../thread/utils/IntegrationTestUtils.java | 110 ++++++ .../server/thread/NsdPublisherTest.java | 214 +++++++++++ 6 files changed, 848 insertions(+), 2 deletions(-) create mode 100644 thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java index c74c023505..440c2c32f4 100644 --- a/thread/service/java/com/android/server/thread/NsdPublisher.java +++ b/thread/service/java/com/android/server/thread/NsdPublisher.java @@ -20,6 +20,7 @@ import static android.net.nsd.NsdManager.PROTOCOL_DNS_SD; import android.annotation.NonNull; import android.content.Context; +import android.net.InetAddresses; import android.net.nsd.NsdManager; import android.net.nsd.NsdServiceInfo; import android.os.Handler; @@ -33,6 +34,7 @@ import com.android.server.thread.openthread.DnsTxtAttribute; import com.android.server.thread.openthread.INsdPublisher; import com.android.server.thread.openthread.INsdStatusReceiver; +import java.net.InetAddress; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; @@ -119,6 +121,30 @@ public final class NsdPublisher extends INsdPublisher.Stub { return serviceInfo; } + @Override + public void registerHost( + String name, List addresses, INsdStatusReceiver receiver, int listenerId) { + postRegistrationJob( + () -> { + NsdServiceInfo serviceInfo = buildServiceInfoForHost(name, addresses); + registerInternal(serviceInfo, receiver, listenerId, "host"); + }); + } + + private static NsdServiceInfo buildServiceInfoForHost( + String name, List addressStrings) { + NsdServiceInfo serviceInfo = new NsdServiceInfo(); + + serviceInfo.setHostname(name); + ArrayList addresses = new ArrayList<>(addressStrings.size()); + for (String addressString : addressStrings) { + addresses.add(InetAddresses.parseNumericAddress(addressString)); + } + serviceInfo.setHostAddresses(addresses); + + return serviceInfo; + } + private void registerInternal( NsdServiceInfo serviceInfo, INsdStatusReceiver receiver, diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp index 6ba192d882..9677ec58bd 100644 --- a/thread/tests/integration/Android.bp +++ b/thread/tests/integration/Android.bp @@ -31,6 +31,7 @@ java_defaults { "net-utils-device-common", "net-utils-device-common-bpf", "testables", + "ThreadNetworkTestUtils", "truth", ], libs: [ diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java new file mode 100644 index 0000000000..9bc92c7386 --- /dev/null +++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java @@ -0,0 +1,351 @@ +/* + * Copyright (C) 2024 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 android.net.thread; + +import static android.Manifest.permission.NETWORK_SETTINGS; +import static android.net.InetAddresses.parseNumericAddress; +import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED; +import static android.net.thread.utils.IntegrationTestUtils.JOIN_TIMEOUT; +import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT; +import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT; +import static android.net.thread.utils.IntegrationTestUtils.discoverForServiceLost; +import static android.net.thread.utils.IntegrationTestUtils.discoverService; +import static android.net.thread.utils.IntegrationTestUtils.isSimulatedThreadRadioSupported; +import static android.net.thread.utils.IntegrationTestUtils.resolveService; +import static android.net.thread.utils.IntegrationTestUtils.resolveServiceUntil; +import static android.net.thread.utils.IntegrationTestUtils.waitFor; + +import static com.android.testutils.TestPermissionUtil.runAsShell; + +import static com.google.common.io.BaseEncoding.base16; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeNotNull; +import static org.junit.Assume.assumeTrue; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.net.thread.utils.FullThreadDevice; +import android.net.thread.utils.OtDaemonController; +import android.net.thread.utils.TapTestNetworkTracker; +import android.os.HandlerThread; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.filters.LargeTest; +import androidx.test.runner.AndroidJUnit4; + +import com.google.common.truth.Correspondence; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.net.Inet6Address; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; + +/** Integration test cases for Service Discovery feature. */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class ServiceDiscoveryTest { + private static final String TAG = ServiceDiscoveryTest.class.getSimpleName(); + private static final int NUM_FTD = 3; + private final Context mContext = ApplicationProvider.getApplicationContext(); + + private HandlerThread mHandlerThread; + private ThreadNetworkController mController; + private NsdManager mNsdManager; + private TapTestNetworkTracker mTestNetworkTracker; + private List mFtds; + + // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new". + private static final byte[] DEFAULT_DATASET_TLVS = + base16().decode( + "0E080000000000010000000300001335060004001FFFE002" + + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31" + + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561" + + "642D643961300102D9A00410A245479C836D551B9CA557F7" + + "B9D351B40C0402A0FFF8"); + private static final ActiveOperationalDataset DEFAULT_DATASET = + ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS); + + private static final Correspondence BYTE_ARRAY_EQUALITY = + Correspondence.from(Arrays::equals, "is equivalent to"); + + @Before + public void setUp() throws Exception { + final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class); + if (manager != null) { + mController = manager.getAllThreadNetworkControllers().get(0); + } + + // Run the tests on only devices where the Thread feature is available. + assumeNotNull(mController); + + // Run the tests only when the device uses simulated Thread radio. + assumeTrue(isSimulatedThreadRadioSupported()); + + // BR forms a network. + CompletableFuture joinFuture = new CompletableFuture<>(); + runAsShell( + PERMISSION_THREAD_NETWORK_PRIVILEGED, + () -> mController.join(DEFAULT_DATASET, directExecutor(), joinFuture::complete)); + joinFuture.get(RESTART_JOIN_TIMEOUT.toMillis(), MILLISECONDS); + + mNsdManager = mContext.getSystemService(NsdManager.class); + + mHandlerThread = new HandlerThread(TAG); + mHandlerThread.start(); + + mTestNetworkTracker = new TapTestNetworkTracker(mContext, mHandlerThread.getLooper()); + assertThat(mTestNetworkTracker).isNotNull(); + runAsShell( + PERMISSION_THREAD_NETWORK_PRIVILEGED, + NETWORK_SETTINGS, + () -> { + CompletableFuture future = new CompletableFuture<>(); + mController.setTestNetworkAsUpstream( + mTestNetworkTracker.getInterfaceName(), + directExecutor(), + v -> future.complete(null)); + future.get(5, SECONDS); + }); + // Create the FTDs in setUp() so that the FTDs can be safely released in tearDown(). + // Don't create new FTDs in test cases. + mFtds = new ArrayList<>(); + for (int i = 0; i < NUM_FTD; ++i) { + FullThreadDevice ftd = new FullThreadDevice(10 + i /* node ID */); + ftd.autoStartSrpClient(); + mFtds.add(ftd); + } + } + + @After + public void tearDown() throws Exception { + if (mController == null) { + return; + } + if (!isSimulatedThreadRadioSupported()) { + return; + } + for (FullThreadDevice ftd : mFtds) { + // Clear registered SRP hosts and services + if (ftd.isSrpHostRegistered()) { + ftd.removeSrpHost(); + } + ftd.destroy(); + } + if (mTestNetworkTracker != null) { + mTestNetworkTracker.tearDown(); + } + if (mHandlerThread != null) { + mHandlerThread.quitSafely(); + mHandlerThread.join(); + } + runAsShell( + PERMISSION_THREAD_NETWORK_PRIVILEGED, + NETWORK_SETTINGS, + () -> { + CompletableFuture setUpstreamFuture = new CompletableFuture<>(); + CompletableFuture leaveFuture = new CompletableFuture<>(); + mController.setTestNetworkAsUpstream( + null, directExecutor(), v -> setUpstreamFuture.complete(null)); + mController.leave(directExecutor(), v -> leaveFuture.complete(null)); + setUpstreamFuture.get(5, SECONDS); + leaveFuture.get(5, SECONDS); + }); + } + + @Test + public void advertisingProxy_multipleSrpClientsRegisterServices_servicesResolvableByMdns() + throws Exception { + /* + *
+         * Topology:
+         *                    Thread
+         *  Border Router -------------- Full Thread device 1
+         *  (Cuttlefish)         |
+         *                       +------ Full Thread device 2
+         *                       |
+         *                       +------ Full Thread device 3
+         * 
+ */ + + // Creates Full Thread Devices (FTD) and let them join the network. + for (FullThreadDevice ftd : mFtds) { + ftd.joinNetwork(DEFAULT_DATASET); + ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT); + } + + int randomId = new Random().nextInt(10_000); + + String serviceNamePrefix = "service-" + randomId + "-"; + String serviceTypePrefix = "_test" + randomId; + String hostnamePrefix = "host-" + randomId + "-"; + + // For every FTD, let it register an SRP service. + for (int i = 0; i < mFtds.size(); ++i) { + FullThreadDevice ftd = mFtds.get(i); + ftd.setSrpHostname(hostnamePrefix + i); + ftd.setSrpHostAddresses(List.of(ftd.getOmrAddress(), ftd.getMlEid())); + ftd.addSrpService( + serviceNamePrefix + i, + serviceTypePrefix + i + "._tcp", + List.of("_sub1", "_sub2"), + 12345 /* port */, + Map.of("key1", bytes(0x01, 0x02), "key2", bytes(i))); + } + + // Check the advertised services are discoverable and resolvable by NsdManager + for (int i = 0; i < mFtds.size(); ++i) { + NsdServiceInfo discoveredService = + discoverService(mNsdManager, serviceTypePrefix + i + "._tcp"); + assertThat(discoveredService).isNotNull(); + NsdServiceInfo resolvedService = resolveService(mNsdManager, discoveredService); + assertThat(resolvedService.getServiceName()).isEqualTo(serviceNamePrefix + i); + assertThat(resolvedService.getServiceType()).isEqualTo(serviceTypePrefix + i + "._tcp"); + assertThat(resolvedService.getPort()).isEqualTo(12345); + assertThat(resolvedService.getAttributes()) + .comparingValuesUsing(BYTE_ARRAY_EQUALITY) + .containsExactly("key1", bytes(0x01, 0x02), "key2", bytes(i)); + assertThat(resolvedService.getHostname()).isEqualTo(hostnamePrefix + i); + assertThat(resolvedService.getHostAddresses()) + .containsExactly(mFtds.get(i).getOmrAddress()); + } + } + + @Test + public void advertisingProxy_srpClientUpdatesService_updatedServiceResolvableByMdns() + throws Exception { + /* + *
+         * Topology:
+         *                    Thread
+         *  Border Router -------------- Full Thread device
+         *  (Cuttlefish)
+         * 
+ */ + + // Creates a Full Thread Devices (FTD) and let it join the network. + FullThreadDevice ftd = mFtds.get(0); + ftd.joinNetwork(DEFAULT_DATASET); + ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT); + ftd.setSrpHostname("my-host"); + ftd.setSrpHostAddresses(List.of((Inet6Address) parseNumericAddress("2001:db8::1"))); + ftd.addSrpService( + "my-service", + "_test._tcp", + Collections.emptyList() /* subtypes */, + 12345 /* port */, + Map.of("key1", bytes(0x01, 0x02), "key2", bytes(0x03))); + + // Update the host addresses + ftd.setSrpHostAddresses( + List.of( + (Inet6Address) parseNumericAddress("2001:db8::1"), + (Inet6Address) parseNumericAddress("2001:db8::2"))); + // Update the service + ftd.updateSrpService( + "my-service", "_test._tcp", List.of("_sub3"), 11111, Map.of("key1", bytes(0x04))); + waitFor(ftd::isSrpHostRegistered, SERVICE_DISCOVERY_TIMEOUT); + + // Check the advertised service is discoverable and resolvable by NsdManager + NsdServiceInfo discoveredService = discoverService(mNsdManager, "_test._tcp"); + assertThat(discoveredService).isNotNull(); + NsdServiceInfo resolvedService = + resolveServiceUntil( + mNsdManager, + discoveredService, + s -> s.getPort() == 11111 && s.getHostAddresses().size() == 2); + assertThat(resolvedService.getServiceName()).isEqualTo("my-service"); + assertThat(resolvedService.getServiceType()).isEqualTo("_test._tcp"); + assertThat(resolvedService.getPort()).isEqualTo(11111); + assertThat(resolvedService.getAttributes()) + .comparingValuesUsing(BYTE_ARRAY_EQUALITY) + .containsExactly("key1", bytes(0x04)); + assertThat(resolvedService.getHostname()).isEqualTo("my-host"); + assertThat(resolvedService.getHostAddresses()) + .containsExactly( + parseNumericAddress("2001:db8::1"), parseNumericAddress("2001:db8::2")); + } + + @Test + public void advertisingProxy_srpClientUnregistersService_serviceIsNotDiscoverableByMdns() + throws Exception { + /* + *
+         * Topology:
+         *                    Thread
+         *  Border Router -------------- Full Thread device
+         *  (Cuttlefish)
+         * 
+ */ + + // Creates a Full Thread Devices (FTD) and let it join the network. + FullThreadDevice ftd = mFtds.get(0); + ftd.joinNetwork(DEFAULT_DATASET); + ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT); + ftd.setSrpHostname("my-host"); + ftd.setSrpHostAddresses( + List.of( + (Inet6Address) parseNumericAddress("2001:db8::1"), + (Inet6Address) parseNumericAddress("2001:db8::2"))); + ftd.addSrpService( + "my-service", + "_test._udp", + List.of("_sub1"), + 12345 /* port */, + Map.of("key1", bytes(0x01, 0x02), "key2", bytes(0x03))); + // Wait for the service to be discoverable by NsdManager. + assertThat(discoverService(mNsdManager, "_test._udp")).isNotNull(); + + // Unregister the service. + CompletableFuture serviceLostFuture = new CompletableFuture<>(); + NsdManager.DiscoveryListener listener = + discoverForServiceLost(mNsdManager, "_test._udp", serviceLostFuture); + ftd.removeSrpService("my-service", "_test._udp", true /* notifyServer */); + + // Verify the service becomes lost. + try { + serviceLostFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS); + } finally { + mNsdManager.stopServiceDiscovery(listener); + } + assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_test._udp")); + } + + private static byte[] bytes(int... byteInts) { + byte[] bytes = new byte[byteInts.length]; + for (int i = 0; i < byteInts.length; ++i) { + bytes[i] = (byte) byteInts[i]; + } + return bytes; + } +} diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java index 6cb16758fe..6306a659e0 100644 --- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java +++ b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java @@ -15,6 +15,7 @@ */ package android.net.thread.utils; +import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT; import static android.net.thread.utils.IntegrationTestUtils.waitFor; import static com.google.common.io.BaseEncoding.base16; @@ -25,15 +26,19 @@ import android.net.InetAddresses; import android.net.IpPrefix; import android.net.thread.ActiveOperationalDataset; +import com.google.errorprone.annotations.FormatMethod; + import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.net.Inet6Address; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -191,7 +196,7 @@ public final class FullThreadDevice { public void udpBind(Inet6Address address, int port) { udpClose(); udpOpen(); - executeCommand(String.format("udp bind %s %d", address.getHostAddress(), port)); + executeCommand("udp bind %s %d", address.getHostAddress(), port); } /** Returns the message received on the UDP socket. */ @@ -204,6 +209,117 @@ public final class FullThreadDevice { return matcher.group(4); } + /** Enables the SRP client and run in autostart mode. */ + public void autoStartSrpClient() { + executeCommand("srp client autostart enable"); + } + + /** Sets the hostname (e.g. "MyHost") for the SRP client. */ + public void setSrpHostname(String hostname) { + executeCommand("srp client host name " + hostname); + } + + /** Sets the host addresses for the SRP client. */ + public void setSrpHostAddresses(List addresses) { + executeCommand( + "srp client host address " + + String.join( + " ", + addresses.stream().map(Inet6Address::getHostAddress).toList())); + } + + /** Removes the SRP host */ + public void removeSrpHost() { + executeCommand("srp client host remove 1 1"); + } + + /** + * Adds an SRP service for the SRP client and wait for the registration to complete. + * + * @param serviceName the service name like "MyService" + * @param serviceType the service type like "_test._tcp" + * @param subtypes the service subtypes like "_sub1" + * @param port the port number in range [1, 65535] + * @param txtMap the map of TXT names and values + * @throws TimeoutException if the service isn't registered within timeout + */ + public void addSrpService( + String serviceName, + String serviceType, + List subtypes, + int port, + Map txtMap) + throws TimeoutException { + StringBuilder fullServiceType = new StringBuilder(serviceType); + for (String subtype : subtypes) { + fullServiceType.append(",").append(subtype); + } + executeCommand( + "srp client service add %s %s %d %d %d %s", + serviceName, + fullServiceType, + port, + 0 /* priority */, + 0 /* weight */, + txtMapToHexString(txtMap)); + waitFor(() -> isSrpServiceRegistered(serviceName, serviceType), SERVICE_DISCOVERY_TIMEOUT); + } + + /** + * Removes an SRP service for the SRP client. + * + * @param serviceName the service name like "MyService" + * @param serviceType the service type like "_test._tcp" + * @param notifyServer whether to notify SRP server about the removal + */ + public void removeSrpService(String serviceName, String serviceType, boolean notifyServer) { + String verb = notifyServer ? "remove" : "clear"; + executeCommand("srp client service %s %s %s", verb, serviceName, serviceType); + } + + /** + * Updates an existing SRP service for the SRP client. + * + *

This is essentially a 'remove' and an 'add' on the SRP client's side. + * + * @param serviceName the service name like "MyService" + * @param serviceType the service type like "_test._tcp" + * @param subtypes the service subtypes like "_sub1" + * @param port the port number in range [1, 65535] + * @param txtMap the map of TXT names and values + * @throws TimeoutException if the service isn't updated within timeout + */ + public void updateSrpService( + String serviceName, + String serviceType, + List subtypes, + int port, + Map txtMap) + throws TimeoutException { + removeSrpService(serviceName, serviceType, false /* notifyServer */); + addSrpService(serviceName, serviceType, subtypes, port, txtMap); + } + + /** Checks if an SRP service is registered. */ + public boolean isSrpServiceRegistered(String serviceName, String serviceType) { + List lines = executeCommand("srp client service"); + for (String line : lines) { + if (line.contains(serviceName) && line.contains(serviceType)) { + return line.contains("Registered"); + } + } + return false; + } + + /** Checks if an SRP host is registered. */ + public boolean isSrpHostRegistered() { + List lines = executeCommand("srp client host"); + for (String line : lines) { + return line.contains("Registered"); + } + return false; + } + /** Runs the "factoryreset" command on the device. */ public void factoryReset() { try { @@ -240,6 +356,11 @@ public final class FullThreadDevice { ping(address, null, 100 /* size */, 1 /* count */); } + @FormatMethod + private List executeCommand(String commandFormat, Object... args) { + return executeCommand(String.format(commandFormat, args)); + } + private List executeCommand(String command) { try { mWriter.write(command + "\n"); @@ -263,7 +384,7 @@ public final class FullThreadDevice { if (line.equals("Done")) { break; } - if (line.startsWith("Error:")) { + if (line.startsWith("Error")) { fail("ot-cli-ftd reported an error: " + line); } if (!line.startsWith("> ")) { @@ -272,4 +393,27 @@ public final class FullThreadDevice { } return result; } + + private static String txtMapToHexString(Map txtMap) { + if (txtMap == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : txtMap.entrySet()) { + int length = entry.getKey().length() + entry.getValue().length + 1; + sb.append(String.format("%02x", length)); + sb.append(toHexString(entry.getKey())); + sb.append(toHexString("=")); + sb.append(toHexString(entry.getValue())); + } + return sb.toString(); + } + + private static String toHexString(String s) { + return toHexString(s.getBytes(StandardCharsets.UTF_8)); + } + + private static String toHexString(byte[] bytes) { + return base16().encode(bytes); + } } diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java index 74251a6e7c..6e70d24da2 100644 --- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java +++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java @@ -23,12 +23,18 @@ import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ROUTER_AD import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + import android.net.TestNetworkInterface; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; import android.net.thread.ThreadNetworkController; import android.os.Handler; import android.os.SystemClock; import android.os.SystemProperties; +import androidx.annotation.NonNull; + import com.android.net.module.util.Struct; import com.android.net.module.util.structs.Icmpv6Header; import com.android.net.module.util.structs.Ipv6Header; @@ -51,6 +57,7 @@ import java.nio.ByteBuffer; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -66,6 +73,7 @@ public final class IntegrationTestUtils { public static final Duration JOIN_TIMEOUT = Duration.ofSeconds(30); public static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2); public static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1); + public static final Duration SERVICE_DISCOVERY_TIMEOUT = Duration.ofSeconds(20); private IntegrationTestUtils() {} @@ -289,4 +297,106 @@ public final class IntegrationTestUtils { } return false; } + + /** Return the first discovered service of {@code serviceType}. */ + public static NsdServiceInfo discoverService(NsdManager nsdManager, String serviceType) + throws Exception { + CompletableFuture serviceInfoFuture = new CompletableFuture<>(); + NsdManager.DiscoveryListener listener = + new DefaultDiscoveryListener() { + @Override + public void onServiceFound(NsdServiceInfo serviceInfo) { + serviceInfoFuture.complete(serviceInfo); + } + }; + nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener); + try { + serviceInfoFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS); + } finally { + nsdManager.stopServiceDiscovery(listener); + } + + return serviceInfoFuture.get(); + } + + /** + * Returns the {@link NsdServiceInfo} when a service instance of {@code serviceType} gets lost. + */ + public static NsdManager.DiscoveryListener discoverForServiceLost( + NsdManager nsdManager, + String serviceType, + CompletableFuture serviceInfoFuture) { + NsdManager.DiscoveryListener listener = + new DefaultDiscoveryListener() { + @Override + public void onServiceLost(NsdServiceInfo serviceInfo) { + serviceInfoFuture.complete(serviceInfo); + } + }; + nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener); + return listener; + } + + /** Resolves the service. */ + public static NsdServiceInfo resolveService(NsdManager nsdManager, NsdServiceInfo serviceInfo) + throws Exception { + return resolveServiceUntil(nsdManager, serviceInfo, s -> true); + } + + /** Returns the first resolved service that satisfies the {@code predicate}. */ + public static NsdServiceInfo resolveServiceUntil( + NsdManager nsdManager, NsdServiceInfo serviceInfo, Predicate predicate) + throws Exception { + CompletableFuture resolvedServiceInfoFuture = new CompletableFuture<>(); + NsdManager.ServiceInfoCallback callback = + new DefaultServiceInfoCallback() { + @Override + public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) { + if (predicate.test(serviceInfo)) { + resolvedServiceInfoFuture.complete(serviceInfo); + } + } + }; + nsdManager.registerServiceInfoCallback(serviceInfo, directExecutor(), callback); + try { + return resolvedServiceInfoFuture.get( + SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS); + } finally { + nsdManager.unregisterServiceInfoCallback(callback); + } + } + + private static class DefaultDiscoveryListener implements NsdManager.DiscoveryListener { + @Override + public void onStartDiscoveryFailed(String serviceType, int errorCode) {} + + @Override + public void onStopDiscoveryFailed(String serviceType, int errorCode) {} + + @Override + public void onDiscoveryStarted(String serviceType) {} + + @Override + public void onDiscoveryStopped(String serviceType) {} + + @Override + public void onServiceFound(NsdServiceInfo serviceInfo) {} + + @Override + public void onServiceLost(NsdServiceInfo serviceInfo) {} + } + + private static class DefaultServiceInfoCallback implements NsdManager.ServiceInfoCallback { + @Override + public void onServiceInfoCallbackRegistrationFailed(int errorCode) {} + + @Override + public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) {} + + @Override + public void onServiceLost() {} + + @Override + public void onServiceInfoCallbackUnregistered() {} + } } diff --git a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java index 8aea0a3ff2..54e89b156d 100644 --- a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java +++ b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import android.net.InetAddresses; import android.net.nsd.NsdManager; import android.net.nsd.NsdServiceInfo; import android.os.Handler; @@ -42,6 +43,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.net.InetAddress; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; @@ -281,6 +284,189 @@ public final class NsdPublisherTest { verify(mUnregistrationReceiver, times(1)).onError(0); } + @Test + public void registerHost_nsdManagerSucceeds_serviceRegistrationSucceeds() throws Exception { + prepareTest(); + + mNsdPublisher.registerHost( + "MyHost", + List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"), + mRegistrationReceiver, + 16 /* listenerId */); + + mTestLooper.dispatchAll(); + + ArgumentCaptor actualServiceInfoCaptor = + ArgumentCaptor.forClass(NsdServiceInfo.class); + ArgumentCaptor actualRegistrationListenerCaptor = + ArgumentCaptor.forClass(NsdManager.RegistrationListener.class); + + verify(mMockNsdManager, times(1)) + .registerService( + actualServiceInfoCaptor.capture(), + eq(PROTOCOL_DNS_SD), + any(), + actualRegistrationListenerCaptor.capture()); + + NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue(); + NsdManager.RegistrationListener actualRegistrationListener = + actualRegistrationListenerCaptor.getValue(); + + actualRegistrationListener.onServiceRegistered(actualServiceInfo); + mTestLooper.dispatchAll(); + + assertThat(actualServiceInfo.getServiceName()).isNull(); + assertThat(actualServiceInfo.getServiceType()).isNull(); + assertThat(actualServiceInfo.getSubtypes()).isEmpty(); + assertThat(actualServiceInfo.getPort()).isEqualTo(0); + assertThat(actualServiceInfo.getAttributes()).isEmpty(); + assertThat(actualServiceInfo.getHostname()).isEqualTo("MyHost"); + assertThat(actualServiceInfo.getHostAddresses()) + .isEqualTo(makeAddresses("2001:db8::1", "2001:db8::2", "2001:db8::3")); + + verify(mRegistrationReceiver, times(1)).onSuccess(); + } + + @Test + public void registerHost_nsdManagerFails_serviceRegistrationFails() throws Exception { + prepareTest(); + + mNsdPublisher.registerHost( + "MyHost", + List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"), + mRegistrationReceiver, + 16 /* listenerId */); + + mTestLooper.dispatchAll(); + + ArgumentCaptor actualServiceInfoCaptor = + ArgumentCaptor.forClass(NsdServiceInfo.class); + ArgumentCaptor actualRegistrationListenerCaptor = + ArgumentCaptor.forClass(NsdManager.RegistrationListener.class); + + verify(mMockNsdManager, times(1)) + .registerService( + actualServiceInfoCaptor.capture(), + eq(PROTOCOL_DNS_SD), + any(), + actualRegistrationListenerCaptor.capture()); + mTestLooper.dispatchAll(); + + NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue(); + NsdManager.RegistrationListener actualRegistrationListener = + actualRegistrationListenerCaptor.getValue(); + + actualRegistrationListener.onRegistrationFailed(actualServiceInfo, FAILURE_INTERNAL_ERROR); + mTestLooper.dispatchAll(); + + assertThat(actualServiceInfo.getServiceName()).isNull(); + assertThat(actualServiceInfo.getServiceType()).isNull(); + assertThat(actualServiceInfo.getSubtypes()).isEmpty(); + assertThat(actualServiceInfo.getPort()).isEqualTo(0); + assertThat(actualServiceInfo.getAttributes()).isEmpty(); + assertThat(actualServiceInfo.getHostname()).isEqualTo("MyHost"); + assertThat(actualServiceInfo.getHostAddresses()) + .isEqualTo(makeAddresses("2001:db8::1", "2001:db8::2", "2001:db8::3")); + + verify(mRegistrationReceiver, times(1)).onError(FAILURE_INTERNAL_ERROR); + } + + @Test + public void registerHost_nsdManagerThrows_serviceRegistrationFails() throws Exception { + prepareTest(); + + doThrow(new IllegalArgumentException("NsdManager fails")) + .when(mMockNsdManager) + .registerService(any(), anyInt(), any(Executor.class), any()); + + mNsdPublisher.registerHost( + "MyHost", + List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"), + mRegistrationReceiver, + 16 /* listenerId */); + + mTestLooper.dispatchAll(); + + verify(mRegistrationReceiver, times(1)).onError(FAILURE_INTERNAL_ERROR); + } + + @Test + public void unregisterHost_nsdManagerSucceeds_serviceUnregistrationSucceeds() throws Exception { + prepareTest(); + + mNsdPublisher.registerHost( + "MyHost", + List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"), + mRegistrationReceiver, + 16 /* listenerId */); + + mTestLooper.dispatchAll(); + + ArgumentCaptor actualServiceInfoCaptor = + ArgumentCaptor.forClass(NsdServiceInfo.class); + ArgumentCaptor actualRegistrationListenerCaptor = + ArgumentCaptor.forClass(NsdManager.RegistrationListener.class); + + verify(mMockNsdManager, times(1)) + .registerService( + actualServiceInfoCaptor.capture(), + eq(PROTOCOL_DNS_SD), + any(Executor.class), + actualRegistrationListenerCaptor.capture()); + + NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue(); + NsdManager.RegistrationListener actualRegistrationListener = + actualRegistrationListenerCaptor.getValue(); + + actualRegistrationListener.onServiceRegistered(actualServiceInfo); + mNsdPublisher.unregister(mUnregistrationReceiver, 16 /* listenerId */); + mTestLooper.dispatchAll(); + verify(mMockNsdManager, times(1)).unregisterService(actualRegistrationListener); + + actualRegistrationListener.onServiceUnregistered(actualServiceInfo); + mTestLooper.dispatchAll(); + verify(mUnregistrationReceiver, times(1)).onSuccess(); + } + + @Test + public void unregisterHost_nsdManagerFails_serviceUnregistrationFails() throws Exception { + prepareTest(); + + mNsdPublisher.registerHost( + "MyHost", + List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"), + mRegistrationReceiver, + 16 /* listenerId */); + + mTestLooper.dispatchAll(); + + ArgumentCaptor actualServiceInfoCaptor = + ArgumentCaptor.forClass(NsdServiceInfo.class); + ArgumentCaptor actualRegistrationListenerCaptor = + ArgumentCaptor.forClass(NsdManager.RegistrationListener.class); + + verify(mMockNsdManager, times(1)) + .registerService( + actualServiceInfoCaptor.capture(), + eq(PROTOCOL_DNS_SD), + any(Executor.class), + actualRegistrationListenerCaptor.capture()); + + NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue(); + NsdManager.RegistrationListener actualRegistrationListener = + actualRegistrationListenerCaptor.getValue(); + + actualRegistrationListener.onServiceRegistered(actualServiceInfo); + mNsdPublisher.unregister(mUnregistrationReceiver, 16 /* listenerId */); + mTestLooper.dispatchAll(); + verify(mMockNsdManager, times(1)).unregisterService(actualRegistrationListener); + + actualRegistrationListener.onUnregistrationFailed( + actualServiceInfo, FAILURE_INTERNAL_ERROR); + mTestLooper.dispatchAll(); + verify(mUnregistrationReceiver, times(1)).onError(0); + } + @Test public void onOtDaemonDied_unregisterAll() { prepareTest(); @@ -336,11 +522,30 @@ public final class NsdPublisherTest { actualRegistrationListenerCaptor.getAllValues().get(1); actualListener2.onServiceRegistered(actualServiceInfoCaptor.getValue()); + mNsdPublisher.registerHost( + "Myhost", + List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"), + mRegistrationReceiver, + 18 /* listenerId */); + + mTestLooper.dispatchAll(); + + verify(mMockNsdManager, times(3)) + .registerService( + actualServiceInfoCaptor.capture(), + eq(PROTOCOL_DNS_SD), + any(Executor.class), + actualRegistrationListenerCaptor.capture()); + NsdManager.RegistrationListener actualListener3 = + actualRegistrationListenerCaptor.getAllValues().get(1); + actualListener3.onServiceRegistered(actualServiceInfoCaptor.getValue()); + mNsdPublisher.onOtDaemonDied(); mTestLooper.dispatchAll(); verify(mMockNsdManager, times(1)).unregisterService(actualListener1); verify(mMockNsdManager, times(1)).unregisterService(actualListener2); + verify(mMockNsdManager, times(1)).unregisterService(actualListener3); } private static DnsTxtAttribute makeTxtAttribute(String name, List value) { @@ -356,6 +561,15 @@ public final class NsdPublisherTest { return txtAttribute; } + private static List makeAddresses(String... addressStrings) { + List addresses = new ArrayList<>(); + + for (String addressString : addressStrings) { + addresses.add(InetAddresses.parseNumericAddress(addressString)); + } + return addresses; + } + // @Before and @Test run in different threads. NsdPublisher requires the jobs are run on the // thread looper, so TestLooper needs to be created inside each test case to install the // correct looper.