[mdns] support regsitering hosts in NsdPublisher
This CL also adds an integration test case for the Advertising Proxy feature.
Currently the test case is disabled because it's unstable on some branches.
Will re-enable the test case when it's stable enough.
Bug: 318323473
Test: atest ThreadNetworkIntegrationTests:android.net.thread.ServiceDiscoveryTest
Change-Id: Ic88efb3b80640f95b53e4ec2428369e87be71649
diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java
index c74c023..440c2c3 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 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.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 @@
return serviceInfo;
}
+ @Override
+ public void registerHost(
+ String name, List<String> addresses, INsdStatusReceiver receiver, int listenerId) {
+ postRegistrationJob(
+ () -> {
+ NsdServiceInfo serviceInfo = buildServiceInfoForHost(name, addresses);
+ registerInternal(serviceInfo, receiver, listenerId, "host");
+ });
+ }
+
+ private static NsdServiceInfo buildServiceInfoForHost(
+ String name, List<String> addressStrings) {
+ NsdServiceInfo serviceInfo = new NsdServiceInfo();
+
+ serviceInfo.setHostname(name);
+ ArrayList<InetAddress> 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 6ba192d..9677ec5 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -31,6 +31,7 @@
"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 0000000..5f1f76a
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -0,0 +1,353 @@
+/*
+ * 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.Ignore;
+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
+@Ignore("TODO: b/328527773 - enable the test when it's stable")
+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<FullThreadDevice> 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[], byte[]> 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<Void> 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<Void> 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<Void> setUpstreamFuture = new CompletableFuture<>();
+ CompletableFuture<Void> 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 {
+ /*
+ * <pre>
+ * Topology:
+ * Thread
+ * Border Router -------------- Full Thread device 1
+ * (Cuttlefish) |
+ * +------ Full Thread device 2
+ * |
+ * +------ Full Thread device 3
+ * </pre>
+ */
+
+ // 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 {
+ /*
+ * <pre>
+ * Topology:
+ * Thread
+ * Border Router -------------- Full Thread device
+ * (Cuttlefish)
+ * </pre>
+ */
+
+ // 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 {
+ /*
+ * <pre>
+ * Topology:
+ * Thread
+ * Border Router -------------- Full Thread device
+ * (Cuttlefish)
+ * </pre>
+ */
+
+ // 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<NsdServiceInfo> 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 6cb1675..6306a65 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.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 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 @@
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<Inet6Address> 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<String> subtypes,
+ int port,
+ Map<String, byte[]> 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.
+ *
+ * <p>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<String> subtypes,
+ int port,
+ Map<String, byte[]> 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<String> 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<String> 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 @@
ping(address, null, 100 /* size */, 1 /* count */);
}
+ @FormatMethod
+ private List<String> executeCommand(String commandFormat, Object... args) {
+ return executeCommand(String.format(commandFormat, args));
+ }
+
private List<String> executeCommand(String command) {
try {
mWriter.write(command + "\n");
@@ -263,7 +384,7 @@
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 @@
}
return result;
}
+
+ private static String txtMapToHexString(Map<String, byte[]> txtMap) {
+ if (txtMap == null) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry<String, byte[]> 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 74251a6..6e70d24 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.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.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 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 @@
}
return false;
}
+
+ /** Return the first discovered service of {@code serviceType}. */
+ public static NsdServiceInfo discoverService(NsdManager nsdManager, String serviceType)
+ throws Exception {
+ CompletableFuture<NsdServiceInfo> 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<NsdServiceInfo> 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<NsdServiceInfo> predicate)
+ throws Exception {
+ CompletableFuture<NsdServiceInfo> 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 8aea0a3..54e89b1 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.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.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;
@@ -282,6 +285,189 @@
}
@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<NsdServiceInfo> actualServiceInfoCaptor =
+ ArgumentCaptor.forClass(NsdServiceInfo.class);
+ ArgumentCaptor<NsdManager.RegistrationListener> 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<NsdServiceInfo> actualServiceInfoCaptor =
+ ArgumentCaptor.forClass(NsdServiceInfo.class);
+ ArgumentCaptor<NsdManager.RegistrationListener> 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<NsdServiceInfo> actualServiceInfoCaptor =
+ ArgumentCaptor.forClass(NsdServiceInfo.class);
+ ArgumentCaptor<NsdManager.RegistrationListener> 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<NsdServiceInfo> actualServiceInfoCaptor =
+ ArgumentCaptor.forClass(NsdServiceInfo.class);
+ ArgumentCaptor<NsdManager.RegistrationListener> 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 @@
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<Integer> value) {
@@ -356,6 +561,15 @@
return txtAttribute;
}
+ private static List<InetAddress> makeAddresses(String... addressStrings) {
+ List<InetAddress> 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.