diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java new file mode 100644 index 0000000000000000000000000000000000000000..c74c0235057593d7bcb382aa1e1b5bb69e513573 --- /dev/null +++ b/thread/service/java/com/android/server/thread/NsdPublisher.java @@ -0,0 +1,335 @@ +/* + * 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 com.android.server.thread; + +import static android.net.nsd.NsdManager.PROTOCOL_DNS_SD; + +import android.annotation.NonNull; +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.os.Handler; +import android.os.RemoteException; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.thread.openthread.DnsTxtAttribute; +import com.android.server.thread.openthread.INsdPublisher; +import com.android.server.thread.openthread.INsdStatusReceiver; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.Executor; + +/** + * Implementation of {@link INsdPublisher}. + * + * <p>This class provides API for service registration and discovery over mDNS. This class is a + * proxy between ot-daemon and NsdManager. + * + * <p>All the data members of this class MUST be accessed in the {@code mHandler}'s Thread except + * {@code mHandler} itself. + * + * <p>TODO: b/323300118 - Remove the following mechanism when the race condition in NsdManager is + * fixed. + * + * <p>There's always only one running registration job at any timepoint. All other pending jobs are + * queued in {@code mRegistrationJobs}. When a registration job is complete (i.e. the according + * method in {@link NsdManager.RegistrationListener} is called), it will start the next registration + * job in the queue. + */ +public final class NsdPublisher extends INsdPublisher.Stub { + // TODO: b/321883491 - specify network for mDNS operations + private static final String TAG = NsdPublisher.class.getSimpleName(); + private final NsdManager mNsdManager; + private final Handler mHandler; + private final Executor mExecutor; + private final SparseArray<RegistrationListener> mRegistrationListeners = new SparseArray<>(0); + private final Deque<Runnable> mRegistrationJobs = new ArrayDeque<>(); + + @VisibleForTesting + public NsdPublisher(NsdManager nsdManager, Handler handler) { + mNsdManager = nsdManager; + mHandler = handler; + mExecutor = runnable -> mHandler.post(runnable); + } + + public static NsdPublisher newInstance(Context context, Handler handler) { + return new NsdPublisher(context.getSystemService(NsdManager.class), handler); + } + + @Override + public void registerService( + String hostname, + String name, + String type, + List<String> subTypeList, + int port, + List<DnsTxtAttribute> txt, + INsdStatusReceiver receiver, + int listenerId) { + postRegistrationJob( + () -> { + NsdServiceInfo serviceInfo = + buildServiceInfoForService( + hostname, name, type, subTypeList, port, txt); + registerInternal(serviceInfo, receiver, listenerId, "service"); + }); + } + + private static NsdServiceInfo buildServiceInfoForService( + String hostname, + String name, + String type, + List<String> subTypeList, + int port, + List<DnsTxtAttribute> txt) { + NsdServiceInfo serviceInfo = new NsdServiceInfo(); + + serviceInfo.setServiceName(name); + if (!TextUtils.isEmpty(hostname)) { + serviceInfo.setHostname(hostname); + } + serviceInfo.setServiceType(type); + serviceInfo.setPort(port); + serviceInfo.setSubtypes(new HashSet<>(subTypeList)); + for (DnsTxtAttribute attribute : txt) { + serviceInfo.setAttribute(attribute.name, attribute.value); + } + + return serviceInfo; + } + + private void registerInternal( + NsdServiceInfo serviceInfo, + INsdStatusReceiver receiver, + int listenerId, + String registrationType) { + checkOnHandlerThread(); + Log.i( + TAG, + "Registering " + + registrationType + + ". Listener ID: " + + listenerId + + ", serviceInfo: " + + serviceInfo); + RegistrationListener listener = new RegistrationListener(serviceInfo, listenerId, receiver); + mRegistrationListeners.append(listenerId, listener); + try { + mNsdManager.registerService(serviceInfo, PROTOCOL_DNS_SD, mExecutor, listener); + } catch (IllegalArgumentException e) { + Log.i(TAG, "Failed to register service. serviceInfo: " + serviceInfo, e); + listener.onRegistrationFailed(serviceInfo, NsdManager.FAILURE_INTERNAL_ERROR); + } + } + + public void unregister(INsdStatusReceiver receiver, int listenerId) { + postRegistrationJob(() -> unregisterInternal(receiver, listenerId)); + } + + public void unregisterInternal(INsdStatusReceiver receiver, int listenerId) { + checkOnHandlerThread(); + RegistrationListener registrationListener = mRegistrationListeners.get(listenerId); + if (registrationListener == null) { + Log.w( + TAG, + "Failed to unregister service." + + " Listener ID: " + + listenerId + + " The registrationListener is empty."); + + return; + } + Log.i( + TAG, + "Unregistering service." + + " Listener ID: " + + listenerId + + " serviceInfo: " + + registrationListener.mServiceInfo); + registrationListener.addUnregistrationReceiver(receiver); + mNsdManager.unregisterService(registrationListener); + } + + private void checkOnHandlerThread() { + if (mHandler.getLooper().getThread() != Thread.currentThread()) { + throw new IllegalStateException( + "Not running on handler Thread: " + Thread.currentThread().getName()); + } + } + + /** On ot-daemon died, unregister all registrations. */ + public void onOtDaemonDied() { + checkOnHandlerThread(); + for (int i = 0; i < mRegistrationListeners.size(); ++i) { + try { + mNsdManager.unregisterService(mRegistrationListeners.valueAt(i)); + } catch (IllegalArgumentException e) { + Log.i( + TAG, + "Failed to unregister." + + " Listener ID: " + + mRegistrationListeners.keyAt(i) + + " serviceInfo: " + + mRegistrationListeners.valueAt(i).mServiceInfo, + e); + } + } + mRegistrationListeners.clear(); + } + + // TODO: b/323300118 - Remove this mechanism when the race condition in NsdManager is fixed. + /** Fetch the first job from the queue and run it. See the class doc for more details. */ + private void peekAndRun() { + if (mRegistrationJobs.isEmpty()) { + return; + } + Runnable job = mRegistrationJobs.getFirst(); + job.run(); + } + + // TODO: b/323300118 - Remove this mechanism when the race condition in NsdManager is fixed. + /** + * Pop the first job from the queue and run the next job. See the class doc for more details. + */ + private void popAndRunNext() { + if (mRegistrationJobs.isEmpty()) { + Log.i(TAG, "No registration jobs when trying to pop and run next."); + return; + } + mRegistrationJobs.removeFirst(); + peekAndRun(); + } + + private void postRegistrationJob(Runnable registrationJob) { + mHandler.post( + () -> { + mRegistrationJobs.addLast(registrationJob); + if (mRegistrationJobs.size() == 1) { + peekAndRun(); + } + }); + } + + private final class RegistrationListener implements NsdManager.RegistrationListener { + private final NsdServiceInfo mServiceInfo; + private final int mListenerId; + private final INsdStatusReceiver mRegistrationReceiver; + private final List<INsdStatusReceiver> mUnregistrationReceivers; + + RegistrationListener( + @NonNull NsdServiceInfo serviceInfo, + int listenerId, + @NonNull INsdStatusReceiver registrationReceiver) { + mServiceInfo = serviceInfo; + mListenerId = listenerId; + mRegistrationReceiver = registrationReceiver; + mUnregistrationReceivers = new ArrayList<>(); + } + + void addUnregistrationReceiver(@NonNull INsdStatusReceiver unregistrationReceiver) { + mUnregistrationReceivers.add(unregistrationReceiver); + } + + @Override + public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) { + checkOnHandlerThread(); + mRegistrationListeners.remove(mListenerId); + Log.i( + TAG, + "Failed to register listener ID: " + + mListenerId + + " error code: " + + errorCode + + " serviceInfo: " + + serviceInfo); + try { + mRegistrationReceiver.onError(errorCode); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + popAndRunNext(); + } + + @Override + public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) { + checkOnHandlerThread(); + for (INsdStatusReceiver receiver : mUnregistrationReceivers) { + Log.i( + TAG, + "Failed to unregister." + + "Listener ID: " + + mListenerId + + ", error code: " + + errorCode + + ", serviceInfo: " + + serviceInfo); + try { + receiver.onError(errorCode); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + } + popAndRunNext(); + } + + @Override + public void onServiceRegistered(NsdServiceInfo serviceInfo) { + checkOnHandlerThread(); + Log.i( + TAG, + "Registered successfully. " + + "Listener ID: " + + mListenerId + + ", serviceInfo: " + + serviceInfo); + try { + mRegistrationReceiver.onSuccess(); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + popAndRunNext(); + } + + @Override + public void onServiceUnregistered(NsdServiceInfo serviceInfo) { + checkOnHandlerThread(); + for (INsdStatusReceiver receiver : mUnregistrationReceivers) { + Log.i( + TAG, + "Unregistered successfully. " + + "Listener ID: " + + mListenerId + + ", serviceInfo: " + + serviceInfo); + try { + receiver.onSuccess(); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + } + mRegistrationListeners.remove(mListenerId); + popAndRunNext(); + } + } +} diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java index b5f72301f1c295570a62a6a7ed9ba49816cb280e..21e3927a5bf2b0274f9b93757cfeaca2f0e1cc59 100644 --- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java +++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java @@ -149,6 +149,7 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub private final ConnectivityManager mConnectivityManager; private final TunInterfaceController mTunIfController; private final InfraInterfaceController mInfraIfController; + private final NsdPublisher mNsdPublisher; private final OtDaemonCallbackProxy mOtDaemonCallbackProxy = new OtDaemonCallbackProxy(); // TODO(b/308310823): read supported channel from Thread dameon @@ -178,7 +179,8 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub ConnectivityManager connectivityManager, TunInterfaceController tunIfController, InfraInterfaceController infraIfController, - ThreadPersistentSettings persistentSettings) { + ThreadPersistentSettings persistentSettings, + NsdPublisher nsdPublisher) { mContext = context; mHandler = handler; mNetworkProvider = networkProvider; @@ -190,24 +192,27 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub mNetworkToInterface = new HashMap<Network, String>(); mBorderRouterConfig = new BorderRouterConfigurationParcel(); mPersistentSettings = persistentSettings; + mNsdPublisher = nsdPublisher; } public static ThreadNetworkControllerService newInstance( Context context, ThreadPersistentSettings persistentSettings) { HandlerThread handlerThread = new HandlerThread("ThreadHandlerThread"); handlerThread.start(); + Handler handler = new Handler(handlerThread.getLooper()); NetworkProvider networkProvider = new NetworkProvider(context, handlerThread.getLooper(), "ThreadNetworkProvider"); return new ThreadNetworkControllerService( context, - new Handler(handlerThread.getLooper()), + handler, networkProvider, () -> IOtDaemon.Stub.asInterface(ServiceManagerWrapper.waitForService("ot_daemon")), context.getSystemService(ConnectivityManager.class), new TunInterfaceController(TUN_IF_NAME), new InfraInterfaceController(), - persistentSettings); + persistentSettings, + NsdPublisher.newInstance(context, handler)); } private static Inet6Address bytesToInet6Address(byte[] addressBytes) { @@ -285,7 +290,8 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub } otDaemon.initialize( mTunIfController.getTunFd(), - mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED)); + mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED), + mNsdPublisher); otDaemon.registerStateCallback(mOtDaemonCallbackProxy, -1); otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0); mOtDaemon = otDaemon; @@ -299,7 +305,7 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub OperationReceiverWrapper.onOtDaemonDied(); mOtDaemonCallbackProxy.onOtDaemonDied(); mTunIfController.onOtDaemonDied(); - + mNsdPublisher.onOtDaemonDied(); mOtDaemon = null; initializeOtDaemon(); } diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp index 81e24da2972f36f00ca06d23218afddd3d7e2594..522120c7c1099a9883b707a1920fdff6457eb208 100644 --- a/thread/tests/cts/Android.bp +++ b/thread/tests/cts/Android.bp @@ -51,4 +51,5 @@ android_test { // Test coverage system runs on different devices. Need to // compile for all architectures. compile_multilib: "both", + platform_apis: true, } diff --git a/thread/tests/cts/AndroidManifest.xml b/thread/tests/cts/AndroidManifest.xml index 4370fe3554a4a7472888d621d1d7a79b8517385c..1541bf5459d8b03a8b323274dd21ea38150780f6 100644 --- a/thread/tests/cts/AndroidManifest.xml +++ b/thread/tests/cts/AndroidManifest.xml @@ -19,6 +19,9 @@ xmlns:android="http://schemas.android.com/apk/res/android" package="android.net.thread.cts"> + <uses-permission android:name="android.permission.INTERNET"/> + <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> + <application android:debuggable="true"> <uses-library android:name="android.test.runner" /> </application> diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java index aab4b2e454f5056efe640a92fdd36d59438eeb9b..3bec36b46b991180b72a17d81e8894cf8bf87cad 100644 --- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java +++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java @@ -17,6 +17,7 @@ package android.net.thread.cts; import static android.Manifest.permission.ACCESS_NETWORK_STATE; +import static android.Manifest.permission.MANAGE_TEST_NETWORKS; import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_CHILD; import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER; import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_ROUTER; @@ -32,6 +33,7 @@ import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork; import static com.android.testutils.TestPermissionUtil.runAsShell; import static com.google.common.truth.Truth.assertThat; @@ -46,9 +48,12 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.content.Context; import android.net.ConnectivityManager; +import android.net.LinkAddress; import android.net.Network; import android.net.NetworkCapabilities; import android.net.NetworkRequest; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; import android.net.thread.ActiveOperationalDataset; import android.net.thread.OperationalDatasetTimestamp; import android.net.thread.PendingOperationalDataset; @@ -60,6 +65,7 @@ import android.net.thread.ThreadNetworkManager; import android.os.Build; import android.os.OutcomeReceiver; +import androidx.annotation.NonNull; import androidx.test.core.app.ApplicationProvider; import androidx.test.filters.LargeTest; @@ -68,6 +74,7 @@ import com.android.testutils.DevSdkIgnoreRule; import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo; import com.android.testutils.DevSdkIgnoreRunner; import com.android.testutils.FunctionalUtils.ThrowingRunnable; +import com.android.testutils.TestNetworkTracker; import org.junit.After; import org.junit.Before; @@ -75,16 +82,22 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Random; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; /** CTS tests for {@link ThreadNetworkController}. */ @LargeTest @@ -97,6 +110,8 @@ public class ThreadNetworkControllerTest { private static final int NETWORK_CALLBACK_TIMEOUT_MILLIS = 10 * 1000; private static final int CALLBACK_TIMEOUT_MILLIS = 1_000; private static final int ENABLED_TIMEOUT_MILLIS = 2_000; + private static final int SERVICE_DISCOVERY_TIMEOUT_MILLIS = 10 * 1000; + private static final String MESHCOP_SERVICE_TYPE = "_meshcop._udp"; private static final String THREAD_NETWORK_PRIVILEGED = "android.permission.THREAD_NETWORK_PRIVILEGED"; @@ -105,6 +120,7 @@ public class ThreadNetworkControllerTest { private final Context mContext = ApplicationProvider.getApplicationContext(); private ExecutorService mExecutor; private ThreadNetworkController mController; + private NsdManager mNsdManager; private Set<String> mGrantedPermissions; @@ -123,6 +139,8 @@ public class ThreadNetworkControllerTest { assumeNotNull(mController); setEnabledAndWait(mController, true); + + mNsdManager = mContext.getSystemService(NsdManager.class); } @After @@ -809,6 +827,74 @@ public class ThreadNetworkControllerTest { getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(allPermissions); } + @Test + public void meshcopService_threadEnabledButNotJoined_discoveredButNoNetwork() throws Exception { + TestNetworkTracker testNetwork = setUpTestNetwork(); + + setEnabledAndWait(mController, true); + leaveAndWait(mController); + + NsdServiceInfo serviceInfo = + expectServiceResolved( + MESHCOP_SERVICE_TYPE, + SERVICE_DISCOVERY_TIMEOUT_MILLIS, + s -> s.getAttributes().get("at") == null); + + Map<String, byte[]> txtMap = serviceInfo.getAttributes(); + + assertThat(txtMap.get("rv")).isNotNull(); + assertThat(txtMap.get("tv")).isNotNull(); + assertThat(txtMap.get("sb")).isNotNull(); + + tearDownTestNetwork(testNetwork); + } + + @Test + public void meshcopService_joinedNetwork_discoveredHasNetwork() throws Exception { + TestNetworkTracker testNetwork = setUpTestNetwork(); + + String networkName = "TestNet" + new Random().nextInt(10_000); + joinRandomizedDatasetAndWait(mController, networkName); + + Predicate<NsdServiceInfo> predicate = + serviceInfo -> + serviceInfo.getAttributes().get("at") != null + && Arrays.equals( + serviceInfo.getAttributes().get("nn"), + networkName.getBytes(StandardCharsets.UTF_8)); + + NsdServiceInfo resolvedService = + expectServiceResolved( + MESHCOP_SERVICE_TYPE, SERVICE_DISCOVERY_TIMEOUT_MILLIS, predicate); + + Map<String, byte[]> txtMap = resolvedService.getAttributes(); + assertThat(txtMap.get("rv")).isNotNull(); + assertThat(txtMap.get("tv")).isNotNull(); + assertThat(txtMap.get("sb")).isNotNull(); + assertThat(txtMap.get("id").length).isEqualTo(16); + + tearDownTestNetwork(testNetwork); + } + + @Test + public void meshcopService_threadDisabled_notDiscovered() throws Exception { + TestNetworkTracker testNetwork = setUpTestNetwork(); + + CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>(); + NsdManager.DiscoveryListener listener = + discoverForServiceLost(MESHCOP_SERVICE_TYPE, serviceLostFuture); + setEnabledAndWait(mController, false); + + try { + serviceLostFuture.get(10_000, MILLISECONDS); + } finally { + mNsdManager.stopServiceDiscovery(listener); + } + assertThrows(TimeoutException.class, () -> discoverService(MESHCOP_SERVICE_TYPE)); + + tearDownTestNetwork(testNetwork); + } + private static void dropAllPermissions() { getInstrumentation().getUiAutomation().dropShellPermissionIdentity(); } @@ -888,6 +974,12 @@ public class ThreadNetworkControllerTest { runAsShell(THREAD_NETWORK_PRIVILEGED, () -> controller.leave(mExecutor, receiver)); } + private void leaveAndWait(ThreadNetworkController controller) throws Exception { + CompletableFuture<Void> future = new CompletableFuture<>(); + leave(controller, future::complete); + future.get(LEAVE_TIMEOUT_MILLIS, MILLISECONDS); + } + private void scheduleMigration( ThreadNetworkController controller, PendingOperationalDataset pendingDataset, @@ -942,9 +1034,9 @@ public class ThreadNetworkControllerTest { waitForEnabledState(controller, booleanToEnabledState(enabled)); } - private CompletableFuture joinRandomizedDataset(ThreadNetworkController controller) - throws Exception { - ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller); + private CompletableFuture joinRandomizedDataset( + ThreadNetworkController controller, String networkName) throws Exception { + ActiveOperationalDataset activeDataset = newRandomizedDataset(networkName, controller); CompletableFuture<Void> joinFuture = new CompletableFuture<>(); runAsShell( THREAD_NETWORK_PRIVILEGED, @@ -953,7 +1045,12 @@ public class ThreadNetworkControllerTest { } private void joinRandomizedDatasetAndWait(ThreadNetworkController controller) throws Exception { - CompletableFuture<Void> joinFuture = joinRandomizedDataset(controller); + joinRandomizedDatasetAndWait(controller, "TestNet"); + } + + private void joinRandomizedDatasetAndWait( + ThreadNetworkController controller, String networkName) throws Exception { + CompletableFuture<Void> joinFuture = joinRandomizedDataset(controller, networkName); joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS); assertThat(isAttached(controller)).isTrue(); } @@ -1010,4 +1107,103 @@ public class ThreadNetworkControllerTest { fail("Should not have thrown " + e); } } + + // Return the first discovered service instance. + private NsdServiceInfo discoverService(String serviceType) throws Exception { + CompletableFuture<NsdServiceInfo> serviceInfoFuture = new CompletableFuture<>(); + NsdManager.DiscoveryListener listener = + new DefaultDiscoveryListener() { + @Override + public void onServiceFound(NsdServiceInfo serviceInfo) { + serviceInfoFuture.complete(serviceInfo); + } + }; + mNsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener); + try { + serviceInfoFuture.get(SERVICE_DISCOVERY_TIMEOUT_MILLIS, MILLISECONDS); + } finally { + mNsdManager.stopServiceDiscovery(listener); + } + + return serviceInfoFuture.get(); + } + + private NsdManager.DiscoveryListener discoverForServiceLost( + String serviceType, CompletableFuture<NsdServiceInfo> serviceInfoFuture) { + NsdManager.DiscoveryListener listener = + new DefaultDiscoveryListener() { + @Override + public void onServiceLost(NsdServiceInfo serviceInfo) { + serviceInfoFuture.complete(serviceInfo); + } + }; + mNsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener); + return listener; + } + + private NsdServiceInfo expectServiceResolved( + String serviceType, int timeoutMilliseconds, Predicate<NsdServiceInfo> predicate) + throws Exception { + NsdServiceInfo discoveredServiceInfo = discoverService(serviceType); + CompletableFuture<NsdServiceInfo> future = new CompletableFuture<>(); + NsdManager.ServiceInfoCallback callback = + new DefaultServiceInfoCallback() { + @Override + public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) { + if (predicate.test(serviceInfo)) { + future.complete(serviceInfo); + } + } + }; + mNsdManager.registerServiceInfoCallback(discoveredServiceInfo, mExecutor, callback); + try { + return future.get(timeoutMilliseconds, MILLISECONDS); + } finally { + mNsdManager.unregisterServiceInfoCallback(callback); + } + } + + TestNetworkTracker setUpTestNetwork() { + return runAsShell( + MANAGE_TEST_NETWORKS, + () -> initTestNetwork(mContext, new LinkAddress("2001:db8:123::/64"), 10_000)); + } + + void tearDownTestNetwork(TestNetworkTracker testNetwork) { + runAsShell(MANAGE_TEST_NETWORKS, () -> testNetwork.teardown()); + } + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..8aea0a3ff225e453eebcd9dce5801f7d6c23e868 --- /dev/null +++ b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java @@ -0,0 +1,367 @@ +/* + * 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 com.android.server.thread; + +import static android.net.nsd.NsdManager.FAILURE_INTERNAL_ERROR; +import static android.net.nsd.NsdManager.PROTOCOL_DNS_SD; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.os.Handler; +import android.os.test.TestLooper; + +import com.android.server.thread.openthread.DnsTxtAttribute; +import com.android.server.thread.openthread.INsdStatusReceiver; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; + +/** Unit tests for {@link NsdPublisher}. */ +public final class NsdPublisherTest { + @Mock private NsdManager mMockNsdManager; + + @Mock private INsdStatusReceiver mRegistrationReceiver; + @Mock private INsdStatusReceiver mUnregistrationReceiver; + + private TestLooper mTestLooper; + private NsdPublisher mNsdPublisher; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void registerService_nsdManagerSucceeds_serviceRegistrationSucceeds() throws Exception { + prepareTest(); + + DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02)); + DnsTxtAttribute txt2 = makeTxtAttribute("key2", List.of(0x03)); + + mNsdPublisher.registerService( + null, + "MyService", + "_test._tcp", + List.of("_subtype1", "_subtype2"), + 12345, + List.of(txt1, txt2), + 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()).isEqualTo("MyService"); + assertThat(actualServiceInfo.getServiceType()).isEqualTo("_test._tcp"); + assertThat(actualServiceInfo.getSubtypes()).isEqualTo(Set.of("_subtype1", "_subtype2")); + assertThat(actualServiceInfo.getPort()).isEqualTo(12345); + assertThat(actualServiceInfo.getAttributes().size()).isEqualTo(2); + assertThat(actualServiceInfo.getAttributes().get("key1")) + .isEqualTo(new byte[] {(byte) 0x01, (byte) 0x02}); + assertThat(actualServiceInfo.getAttributes().get("key2")) + .isEqualTo(new byte[] {(byte) 0x03}); + + verify(mRegistrationReceiver, times(1)).onSuccess(); + } + + @Test + public void registerService_nsdManagerFails_serviceRegistrationFails() throws Exception { + prepareTest(); + + DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02)); + DnsTxtAttribute txt2 = makeTxtAttribute("key2", List.of(0x03)); + + mNsdPublisher.registerService( + null, + "MyService", + "_test._tcp", + List.of("_subtype1", "_subtype2"), + 12345, + List.of(txt1, txt2), + 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.onRegistrationFailed(actualServiceInfo, FAILURE_INTERNAL_ERROR); + mTestLooper.dispatchAll(); + + assertThat(actualServiceInfo.getServiceName()).isEqualTo("MyService"); + assertThat(actualServiceInfo.getServiceType()).isEqualTo("_test._tcp"); + assertThat(actualServiceInfo.getSubtypes()).isEqualTo(Set.of("_subtype1", "_subtype2")); + assertThat(actualServiceInfo.getPort()).isEqualTo(12345); + assertThat(actualServiceInfo.getAttributes().size()).isEqualTo(2); + assertThat(actualServiceInfo.getAttributes().get("key1")) + .isEqualTo(new byte[] {(byte) 0x01, (byte) 0x02}); + assertThat(actualServiceInfo.getAttributes().get("key2")) + .isEqualTo(new byte[] {(byte) 0x03}); + + verify(mRegistrationReceiver, times(1)).onError(FAILURE_INTERNAL_ERROR); + } + + @Test + public void registerService_nsdManagerThrows_serviceRegistrationFails() throws Exception { + prepareTest(); + + DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02)); + DnsTxtAttribute txt2 = makeTxtAttribute("key2", List.of(0x03)); + + doThrow(new IllegalArgumentException("NsdManager fails")) + .when(mMockNsdManager) + .registerService(any(), anyInt(), any(Executor.class), any()); + + mNsdPublisher.registerService( + null, + "MyService", + "_test._tcp", + List.of("_subtype1", "_subtype2"), + 12345, + List.of(txt1, txt2), + mRegistrationReceiver, + 16 /* listenerId */); + mTestLooper.dispatchAll(); + + verify(mRegistrationReceiver, times(1)).onError(FAILURE_INTERNAL_ERROR); + } + + @Test + public void unregisterService_nsdManagerSucceeds_serviceUnregistrationSucceeds() + throws Exception { + prepareTest(); + + DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02)); + DnsTxtAttribute txt2 = makeTxtAttribute("key2", List.of(0x03)); + + mNsdPublisher.registerService( + null, + "MyService", + "_test._tcp", + List.of("_subtype1", "_subtype2"), + 12345, + List.of(txt1, txt2), + 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 unregisterService_nsdManagerFails_serviceUnregistrationFails() throws Exception { + prepareTest(); + + DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02)); + DnsTxtAttribute txt2 = makeTxtAttribute("key2", List.of(0x03)); + + mNsdPublisher.registerService( + null, + "MyService", + "_test._tcp", + List.of("_subtype1", "_subtype2"), + 12345, + List.of(txt1, txt2), + 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(); + + DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02)); + DnsTxtAttribute txt2 = makeTxtAttribute("key2", List.of(0x03)); + + ArgumentCaptor<NsdServiceInfo> actualServiceInfoCaptor = + ArgumentCaptor.forClass(NsdServiceInfo.class); + ArgumentCaptor<NsdManager.RegistrationListener> actualRegistrationListenerCaptor = + ArgumentCaptor.forClass(NsdManager.RegistrationListener.class); + + mNsdPublisher.registerService( + null, + "MyService", + "_test._tcp", + List.of("_subtype1", "_subtype2"), + 12345, + List.of(txt1, txt2), + mRegistrationReceiver, + 16 /* listenerId */); + mTestLooper.dispatchAll(); + + verify(mMockNsdManager, times(1)) + .registerService( + actualServiceInfoCaptor.capture(), + eq(PROTOCOL_DNS_SD), + any(Executor.class), + actualRegistrationListenerCaptor.capture()); + NsdManager.RegistrationListener actualListener1 = + actualRegistrationListenerCaptor.getValue(); + actualListener1.onServiceRegistered(actualServiceInfoCaptor.getValue()); + + mNsdPublisher.registerService( + null, + "MyService2", + "_test._udp", + Collections.emptyList(), + 11111, + Collections.emptyList(), + mRegistrationReceiver, + 17 /* listenerId */); + + mTestLooper.dispatchAll(); + + verify(mMockNsdManager, times(2)) + .registerService( + actualServiceInfoCaptor.capture(), + eq(PROTOCOL_DNS_SD), + any(Executor.class), + actualRegistrationListenerCaptor.capture()); + NsdManager.RegistrationListener actualListener2 = + actualRegistrationListenerCaptor.getAllValues().get(1); + actualListener2.onServiceRegistered(actualServiceInfoCaptor.getValue()); + + mNsdPublisher.onOtDaemonDied(); + mTestLooper.dispatchAll(); + + verify(mMockNsdManager, times(1)).unregisterService(actualListener1); + verify(mMockNsdManager, times(1)).unregisterService(actualListener2); + } + + private static DnsTxtAttribute makeTxtAttribute(String name, List<Integer> value) { + DnsTxtAttribute txtAttribute = new DnsTxtAttribute(); + + txtAttribute.name = name; + txtAttribute.value = new byte[value.size()]; + + for (int i = 0; i < value.size(); ++i) { + txtAttribute.value[i] = value.get(i).byteValue(); + } + + return txtAttribute; + } + + // @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. + private void prepareTest() { + mTestLooper = new TestLooper(); + Handler handler = new Handler(mTestLooper.getLooper()); + mNsdPublisher = new NsdPublisher(mMockNsdManager, handler); + } +} diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java index 1d83abc04f73a0f0423efe515a2f5476f513dfc7..f626edf32f55f18842872113bba4f83bf811876b 100644 --- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java +++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java @@ -87,6 +87,7 @@ public final class ThreadNetworkControllerServiceTest { @Mock private ParcelFileDescriptor mMockTunFd; @Mock private InfraInterfaceController mMockInfraIfController; @Mock private ThreadPersistentSettings mMockPersistentSettings; + @Mock private NsdPublisher mMockNsdPublisher; private Context mContext; private TestLooper mTestLooper; private FakeOtDaemon mFakeOtDaemon; @@ -117,12 +118,13 @@ public final class ThreadNetworkControllerServiceTest { mMockConnectivityManager, mMockTunIfController, mMockInfraIfController, - mMockPersistentSettings); + mMockPersistentSettings, + mMockNsdPublisher); mService.setTestNetworkAgent(mMockNetworkAgent); } @Test - public void initialize_tunInterfaceSetToOtDaemon() throws Exception { + public void initialize_tunInterfaceAndNsdPublisherSetToOtDaemon() throws Exception { when(mMockTunIfController.getTunFd()).thenReturn(mMockTunFd); mService.initialize(); @@ -130,6 +132,7 @@ public final class ThreadNetworkControllerServiceTest { verify(mMockTunIfController, times(1)).createTunInterface(); assertThat(mFakeOtDaemon.getTunFd()).isEqualTo(mMockTunFd); + assertThat(mFakeOtDaemon.getNsdPublisher()).isEqualTo(mMockNsdPublisher); } @Test