Merge "[mdns] support regsitering hosts in NsdPublisher" into main
This commit is contained in:
commit
b7347bd441
6 changed files with 848 additions and 2 deletions
|
|
@ -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<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,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ java_defaults {
|
|||
"net-utils-device-common",
|
||||
"net-utils-device-common-bpf",
|
||||
"testables",
|
||||
"ThreadNetworkTestUtils",
|
||||
"truth",
|
||||
],
|
||||
libs: [
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 @@ public final class FullThreadDevice {
|
|||
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 @@ 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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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() {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 @@ 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<Integer> value) {
|
||||
|
|
@ -356,6 +561,15 @@ public final class NsdPublisherTest {
|
|||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue