diff --git a/thread/framework/Android.bp b/thread/framework/Android.bp index f8fe4228cb..3763b36d6f 100644 --- a/thread/framework/Android.bp +++ b/thread/framework/Android.bp @@ -21,6 +21,7 @@ package { filegroup { name: "framework-thread-sources", + defaults: ["framework-sources-module-defaults"], srcs: [ "java/**/*.java", "java/**/*.aidl", diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java index 51a3f47e27..5975842d81 100644 --- a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java +++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java @@ -602,17 +602,18 @@ public final class ActiveOperationalDataset implements Parcelable { // SparseArray is not hashable int result = byteArraySparseArrayHashCode(mChannelMask); result = result * 31 + byteArraySparseArrayHashCode(mUnknownTlvs); - return result * 31 + deepHashCode( - mActiveTimestamp, - mNetworkName, - mExtendedPanId, - mPanId, - mChannel, - mChannelPage, - mPskc, - mNetworkKey, - mMeshLocalPrefix, - mSecurityPolicy); + return result * 31 + + deepHashCode( + mActiveTimestamp, + mNetworkName, + mExtendedPanId, + mPanId, + mChannel, + mChannelPage, + mPskc, + mNetworkKey, + mMeshLocalPrefix, + mSecurityPolicy); } @Override diff --git a/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java b/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java index 406de97876..de38a9ed23 100644 --- a/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java +++ b/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java @@ -112,6 +112,10 @@ public final class ThreadNetworkSpecifier extends NetworkSpecifier implements Pa /** @hide */ @Override public NetworkSpecifier redact(long redactions) { + if (mActiveDataset == null) { + return new ThreadNetworkSpecifier.Builder(this).build(); + } + var redactedDatasetBuilder = new ActiveOperationalDataset.Builder(mActiveDataset); if ((redactions & REDACT_FOR_THREAD_NETWORK_PRIVILEGED) != 0 || (redactions & REDACT_FOR_ACCESS_FINE_LOCATION) != 0) { diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java index b040498070..e731a5fb6c 100644 --- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java +++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java @@ -66,6 +66,7 @@ import static com.android.server.thread.openthread.IOtDaemon.OT_STATE_ENABLED; import static com.android.server.thread.openthread.IOtDaemon.TUN_IF_NAME; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNull; import android.Manifest.permission; import android.annotation.NonNull; @@ -89,7 +90,6 @@ import android.net.Network; import android.net.NetworkAgent; import android.net.NetworkAgentConfig; import android.net.NetworkCapabilities; -import android.net.NetworkProvider; import android.net.NetworkRequest; import android.net.NetworkScore; import android.net.TestNetworkSpecifier; @@ -110,11 +110,13 @@ import android.net.thread.ThreadNetworkController; import android.net.thread.ThreadNetworkController.DeviceRole; import android.net.thread.ThreadNetworkException; import android.net.thread.ThreadNetworkException.ErrorCode; +import android.net.thread.ThreadNetworkSpecifier; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; +import android.os.OutcomeReceiver; import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.os.SystemClock; @@ -198,7 +200,7 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub // particular, the constructor does not run on the handler thread, so it must not touch any of // the non-final fields, nor must it mutate any of the non-final fields inside these objects. - private final NetworkProvider mNetworkProvider; + private final ThreadNetworkFactory mNetworkFactory; private final Supplier mOtDaemonSupplier; private final ConnectivityManager mConnectivityManager; private final RoutingCoordinatorManager mRoutingCoordinatorManager; @@ -233,12 +235,14 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub private InfraLinkState mInfraLinkState; + @Nullable private ThreadNetworkSpecifier mJoinNetworkSpecifier; + @VisibleForTesting ThreadNetworkControllerService( Context context, Handler handler, MockableSystemProperties systemProperties, - NetworkProvider networkProvider, + ThreadNetworkFactory networkFactory, Supplier otDaemonSupplier, ConnectivityManager connectivityManager, RoutingCoordinatorManager routingCoordinatorManager, @@ -253,7 +257,7 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub mContext = context; mHandler = handler; mSystemProperties = systemProperties; - mNetworkProvider = networkProvider; + mNetworkFactory = networkFactory; mOtDaemonSupplier = otDaemonSupplier; mConnectivityManager = connectivityManager; mRoutingCoordinatorManager = routingCoordinatorManager; @@ -278,8 +282,6 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub HandlerThread handlerThread = new HandlerThread("ThreadHandlerThread"); handlerThread.start(); Handler handler = new Handler(handlerThread.getLooper()); - NetworkProvider networkProvider = - new NetworkProvider(context, handlerThread.getLooper(), "ThreadNetworkProvider"); Map networkToLinkProperties = new HashMap<>(); final ConnectivityManager connectivityManager = context.getSystemService(ConnectivityManager.class); @@ -291,7 +293,7 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub context, handler, new MockableSystemProperties(), - networkProvider, + ThreadNetworkFactory.newInstance(context, handler.getLooper()), () -> IOtDaemon.Stub.asInterface(CommonConnectivityJni.waitForService("ot_daemon")), connectivityManager, routingCoordinatorManager, @@ -501,7 +503,8 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub } catch (IOException e) { throw new IllegalStateException("Failed to create Thread tunnel interface", e); } - mConnectivityManager.registerNetworkProvider(mNetworkProvider); + mNetworkFactory.initialize(this); + mNetworkFactory.register(); mUserRestricted = isThreadUserRestricted(); registerUserRestrictionsReceiver(); @@ -904,31 +907,47 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub mTestNetworkAgent = testNetworkAgent; } + private NetworkCapabilities computeNetworkCapabilities( + @Nullable ActiveOperationalDataset activeDataset) { + final var netCapsBuilder = + new NetworkCapabilities.Builder() + .addTransportType(TRANSPORT_THREAD) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED) + .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK); + if (activeDataset != null) { + boolean createPartition = + (mJoinNetworkSpecifier != null) + ? mJoinNetworkSpecifier.shouldCreatePartitionIfNotFound() + : false; + final var actualSpecifier = + new ThreadNetworkSpecifier.Builder() + .setActiveOperationalDataset(activeDataset) + .setShouldCreatePartitionIfNotFound(createPartition) + .build(); + netCapsBuilder.setNetworkSpecifier(actualSpecifier); + } + return netCapsBuilder.build(); + } + private NetworkAgent newNetworkAgent() { if (mTestNetworkAgent != null) { return mTestNetworkAgent; } - final var netCapsBuilder = - new NetworkCapabilities.Builder() - .addTransportType(TRANSPORT_THREAD) - .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) - .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED); final var scoreBuilder = new NetworkScore.Builder(); - - netCapsBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK); scoreBuilder.setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK); return new NetworkAgent( mContext, mHandler.getLooper(), LOG.getTag(), - netCapsBuilder.build(), + computeNetworkCapabilities(mOtDaemonCallbackProxy.mActiveDataset), getTunIfLinkProperties(), newLocalNetworkConfig(), scoreBuilder.build(), new NetworkAgentConfig.Builder().build(), - mNetworkProvider) { + mNetworkFactory.getProvider()) { // TODO(b/374037595): use NetworkFactory to handle dynamic network requests @Override @@ -1235,6 +1254,22 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub }; } + private IOtStatusReceiver newOtStatusReceiver( + OutcomeReceiver receiver) { + return new IOtStatusReceiver.Stub() { + @Override + public void onSuccess() { + receiver.onResult(null); + } + + @Override + public void onError(int otError, String message) { + receiver.onError( + new ThreadNetworkException(otErrorToAndroidError(otError), message)); + } + }; + } + private IOtOutputReceiver newOtOutputReceiver(OutputReceiverWrapper receiver) { return new IOtOutputReceiver.Stub() { @Override @@ -1299,15 +1334,46 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub @NonNull OperationReceiverWrapper receiver) { checkOnHandlerThread(); + mJoinNetworkSpecifier = null; try { // The otDaemon.join() will leave first if this device is currently attached - getOtDaemon().join(activeDataset.toThreadTlvs(), newOtStatusReceiver(receiver)); + getOtDaemon() + .join( + activeDataset.toThreadTlvs(), + true /* createPartitionIfNotFound */, + newOtStatusReceiver(receiver)); } catch (RemoteException | ThreadNetworkException e) { LOG.e("otDaemon.join failed", e); receiver.onError(e); } } + void joinWithSpecifier( + ThreadNetworkSpecifier specifier, + OutcomeReceiver receiver) { + mHandler.post(() -> joinWithSpecifierInternal(specifier, receiver)); + } + + private void joinWithSpecifierInternal( + ThreadNetworkSpecifier specifier, + OutcomeReceiver receiver) { + checkOnHandlerThread(); + + mJoinNetworkSpecifier = specifier; + byte[] dataset = requireNonNull(specifier.getActiveOperationalDataset()).toThreadTlvs(); + boolean createPartitionIfNotFound = specifier.shouldCreatePartitionIfNotFound(); + try { + getOtDaemon().join(dataset, createPartitionIfNotFound, newOtStatusReceiver(receiver)); + } catch (RemoteException | ThreadNetworkException e) { + LOG.e("otDaemon.joinWithSpecifier failed", e); + if (e instanceof RemoteException re) { + receiver.onError(new ThreadNetworkException(ERROR_INTERNAL_ERROR, re.getMessage())); + } else { + receiver.onError((ThreadNetworkException) e); + } + } + } + @Override public void scheduleMigration( @NonNull PendingOperationalDataset pendingDataset, @@ -1509,6 +1575,13 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub setInfraLinkState(newInfraLinkStateBuilder().build()); } + private void handleActiveDatasetChanged(@Nullable ActiveOperationalDataset newActiveDataset) { + if (mNetworkAgent == null) { + return; + } + mNetworkAgent.sendNetworkCapabilities(computeNetworkCapabilities(newActiveDataset)); + } + private void handleThreadInterfaceStateChanged(boolean isUp) { try { mTunIfController.setInterfaceUp(isUp); @@ -1994,6 +2067,14 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub ActiveOperationalDataset activeDataset, long listenerId) { checkOnHandlerThread(); boolean hasChange = !Objects.equals(mActiveDataset, activeDataset); + if (hasChange) { + LOG.i( + "Current Active Operational Dataset changed: " + + mActiveDataset + + " -> " + + activeDataset); + handleActiveDatasetChanged(activeDataset); + } for (var callbackEntry : mOpDatasetCallbacks.entrySet()) { if (!hasChange && callbackEntry.getValue().id != listenerId) { diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkFactory.java b/thread/service/java/com/android/server/thread/ThreadNetworkFactory.java new file mode 100644 index 0000000000..ee3bba50aa --- /dev/null +++ b/thread/service/java/com/android/server/thread/ThreadNetworkFactory.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2025 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.NetworkCapabilities.TRANSPORT_THREAD; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.MatchAllNetworkSpecifier; +import android.net.NetworkCapabilities; +import android.net.NetworkFactory; +import android.net.NetworkRequest; +import android.net.thread.IOperationReceiver; +import android.net.thread.ThreadNetworkException; +import android.net.thread.ThreadNetworkSpecifier; +import android.os.Looper; +import android.os.OutcomeReceiver; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.net.module.util.SharedLog; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +// TODO(b/442787508): replace NetworkFactory subclass with direct usage of NetworkProvider +/** + * Serves network requests that are initiated from apps via the {@link + * ConnectivityManager#requestNetwork} API. + */ +public final class ThreadNetworkFactory extends NetworkFactory { + private static final SharedLog LOG = + ThreadNetworkLogger.forSubComponent("ThreadNetworkFactory"); + private final List mActiveRequests = new ArrayList<>(); + private @Nullable ThreadNetworkControllerService mControllerService; + + /** Creates a new {@link ThreadNetworkFactory} instance. */ + public static ThreadNetworkFactory newInstance(Context context, Looper looper) { + return new ThreadNetworkFactory(context, looper); + } + + @VisibleForTesting + ThreadNetworkFactory(Context context, Looper looper) { + super(looper, context, LOG.getTag(), createBaseCapabilities()); + mControllerService = null; + } + + void initialize(ThreadNetworkControllerService controllerService) { + mControllerService = controllerService; + } + + /** Returns the basic capabilities that all Thread networks initially have. */ + private static NetworkCapabilities createBaseCapabilities() { + return new NetworkCapabilities.Builder() + .addTransportType(TRANSPORT_THREAD) + .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED) + .setLinkUpstreamBandwidthKbps(100) + .setLinkDownstreamBandwidthKbps(100) + // By default accepts all network requests which have a specifier + .setNetworkSpecifier(new MatchAllNetworkSpecifier()) + .build(); + } + + @Override + public boolean acceptRequest(@NonNull final NetworkRequest request) { + // the filtering is done in {@link #needNetworkFor} + return true; + } + + @Override + public void needNetworkFor(@NonNull final NetworkRequest request) { + LOG.i("network request is received for " + request); + if (!request.hasTransport(TRANSPORT_THREAD)) { + return; + } + + if (request.getNetworkSpecifier() instanceof ThreadNetworkSpecifier specifier + && specifier.getActiveOperationalDataset() != null) { + mActiveRequests.add(request); + join(specifier); + } else { + // Can do nothing if ThreadNetworkSpecifier or the Dataset is missing + return; + } + } + + private void join(ThreadNetworkSpecifier specifier) { + var receiver = + new OutcomeReceiver() { + @Override + public void onError(ThreadNetworkException e) { + LOG.e("Failed to join Thread network: " + specifier, e); + } + + @Override + public void onResult(Void v) { + LOG.i("Successfully joined Thread network: " + specifier); + } + }; + mControllerService.joinWithSpecifier(specifier, receiver); + } + + @Override + public void releaseNetworkFor(@NonNull final NetworkRequest request) { + LOG.i("network release request is received for " + request); + if (mActiveRequests.isEmpty()) { + LOG.w("Unexpect release of untracked request " + request); + return; + } + + NetworkRequest lastRequest = mActiveRequests.getLast(); + mActiveRequests.removeAll(Collections.singletonList(request)); + + if (mActiveRequests.isEmpty()) { + leave(request); + } else if (!Objects.equals(mActiveRequests.getLast(), lastRequest)) { + // If the last request is changed, let's re-join the network with the new latest + // request + join((ThreadNetworkSpecifier) mActiveRequests.getLast().getNetworkSpecifier()); + } + } + + private void leave(NetworkRequest request) { + var receiver = + new IOperationReceiver.Stub() { + @Override + public void onSuccess() { + LOG.i("Successfully left Thread network for releasing request " + request); + } + + @Override + public void onError(int errorCode, String errorMessage) { + var ex = new ThreadNetworkException(errorCode, errorMessage); + LOG.e( + "Failed to leave Thread network for releasing request " + request, + ex); + } + }; + mControllerService.leave(receiver); + } +} diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkRequestTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkRequestTest.java new file mode 100644 index 0000000000..45957037d7 --- /dev/null +++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkRequestTest.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2025 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.cts; + +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static android.Manifest.permission.THREAD_NETWORK_PRIVILEGED; +import static android.net.ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO; +import static android.net.NetworkCapabilities.TRANSPORT_THREAD; +import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import static com.android.net.thread.flags.Flags.FLAG_THREAD_MOBILE_ENABLED; + +import static com.google.common.io.BaseEncoding.base16; + +import static org.junit.Assert.assertThrows; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkRequest; +import android.net.thread.ActiveOperationalDataset; +import android.net.thread.ThreadNetworkSpecifier; +import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature; +import android.net.thread.utils.ThreadNetworkControllerWrapper; +import android.platform.test.annotations.RequiresFlagsEnabled; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.filters.LargeTest; + +import com.android.testutils.TestableNetworkCallback; +import com.android.testutils.TestableNetworkCallback.Event; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Tests for requesting a Thread network via the {@link ConnectivityManager#requestNetwork} and + * {@link ThreadNetworkSpecifier} API. + */ +@LargeTest +@RequiresThreadFeature +@RequiresFlagsEnabled(FLAG_THREAD_MOBILE_ENABLED) +public final class ThreadNetworkRequestTest { + private static final int JOIN_TIMEOUT_MILLIS = 30_000; + private static final Duration STOP_TIMEOUT = Duration.ofSeconds(2_000); + + // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new": + // Active Timestamp: 1 + // Channel: 13 + // Wake-up Channel: 18 + // Channel Mask: 0x07fff800 + // Ext PAN ID: 492cc112e634e51b + // Mesh Local Prefix: fd2f:357c:dd90:2120::/64 + // Network Key: 9f55333f52eb25dbfdbe62121b39bee4 + // Network Name: OpenThread-145a + // PAN ID: 0x145a + // PSKc: 6d81fe700160ab9f63f50f78ccf0733c + // Security Policy: 672 onrc 0 + private static final byte[] DATASET_TLVS_1 = + base16().ignoreCase() + .decode( + "0e080000000000010000000300000d4a0300001235060004001fff" + + "e00208492cc112e634e51b0708fd2f357cdd90212005109f55333f" + + "52eb25dbfdbe62121b39bee4030f4f70656e5468726561642d3134" + + "35610102145a04106d81fe700160ab9f63f50f78ccf0733c0c0402a0f7f8"); + // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new": + // Active Timestamp: 1 + // Channel: 24 + // Wake-up Channel: 19 + // Channel Mask: 0x07fff800 + // Ext PAN ID: 779732128edb4591 + // Mesh Local Prefix: fdbd:3671:821d:18c1::/64 + // Network Key: 6a643ec02d38ef55b21b26cec46f622d + // Network Name: OpenThread-64be + // PAN ID: 0x64be + // PSKc: 37968aab079fac83fa67b14c9bb7f598 + // Security Policy: 672 onrc 0 + private static final byte[] DATASET_TLVS_2 = + base16().ignoreCase() + .decode( + "0e08000000000001000000030000184a0300001335060004001fff" + + "e00208779732128edb45910708fdbd3671821d18c105106a643ec0" + + "2d38ef55b21b26cec46f622d030f4f70656e5468726561642d3634" + + "6265010264be041037968aab079fac83fa67b14c9bb7f5980c0402a0f7f8"); + + private static final ActiveOperationalDataset TEST_DATASET_1 = + ActiveOperationalDataset.fromThreadTlvs(DATASET_TLVS_1); + private static final ActiveOperationalDataset TEST_DATASET_2 = + ActiveOperationalDataset.fromThreadTlvs(DATASET_TLVS_2); + + private final Context mContext = ApplicationProvider.getApplicationContext(); + private ConnectivityManager cm; + private ThreadNetworkControllerWrapper mController; + private List mCallbacks; + private Set mGrantedPermissions; + + private void grantPermissions(String... permissions) { + for (String permission : permissions) { + mGrantedPermissions.add(permission); + } + String[] allPermissions = new String[mGrantedPermissions.size()]; + mGrantedPermissions.toArray(allPermissions); + getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(allPermissions); + } + + private static void dropAllPermissions() { + getInstrumentation().getUiAutomation().dropShellPermissionIdentity(); + } + + @Before + public void setUp() throws Exception { + mCallbacks = new ArrayList<>(); + mGrantedPermissions = new HashSet(); + cm = mContext.getSystemService(ConnectivityManager.class); + mController = ThreadNetworkControllerWrapper.newInstance(mContext); + mController.setBorderRouterEnabledAndWait(false); + mController.leaveAndWait(); + } + + @After + public void tearDown() throws Exception { + dropAllPermissions(); + for (var callback : mCallbacks) { + cm.unregisterNetworkCallback(callback); + } + mController.waitForRole(DEVICE_ROLE_STOPPED, STOP_TIMEOUT); + } + + private TestableNetworkCallback newNetworkCallback() { + var callback = + new TestableNetworkCallback( + 250L /* timeoutMs */, + 250L /* noCallbackTimeoutMs */, + () -> {} /* waiterFunc */, + "ThreadNetworkRequestTest" /* logTag */, + FLAG_INCLUDE_LOCATION_INFO); + mCallbacks.add(callback); + return callback; + } + + private NetworkRequest newThreadNetworkRequest( + ActiveOperationalDataset dataset, boolean createPartitionIfNotFound) { + var specifier = + new ThreadNetworkSpecifier.Builder() + .setActiveOperationalDataset(dataset) + .setShouldCreatePartitionIfNotFound(createPartitionIfNotFound) + .build(); + return new NetworkRequest.Builder() + .addTransportType(TRANSPORT_THREAD) + .setNetworkSpecifier(specifier) + .build(); + } + + @Test + public void requestNetwork_withoutThreadPrivilegedPermission_failsWithUnavailable() { + dropAllPermissions(); + var request = newThreadNetworkRequest(TEST_DATASET_1, true /* createPartitionIfNotFound */); + var callback = newNetworkCallback(); + + assertThrows(SecurityException.class, () -> cm.requestNetwork(request, callback)); + mCallbacks.remove(callback); + } + + @Test + public void requestNetwork_withCreatePartitionEnabled_succeedsWithAvailable() { + grantPermissions(THREAD_NETWORK_PRIVILEGED, ACCESS_FINE_LOCATION); + var request = newThreadNetworkRequest(TEST_DATASET_1, true /* createPartitionIfNotFound */); + + var callback = newNetworkCallback(); + cm.requestNetwork(request, callback); + + callback.eventuallyExpect(Event.AVAILABLE, JOIN_TIMEOUT_MILLIS); + } + + @Test + public void requestNetwork_multipleRequests_theLastOneWins() { + grantPermissions(THREAD_NETWORK_PRIVILEGED, ACCESS_FINE_LOCATION); + var request1 = + newThreadNetworkRequest(TEST_DATASET_1, true /* createPartitionIfNotFound */); + var request2 = + newThreadNetworkRequest(TEST_DATASET_2, true /* createPartitionIfNotFound */); + + var callback1 = newNetworkCallback(); + var callback2 = newNetworkCallback(); + cm.requestNetwork(request1, callback1); + cm.requestNetwork(request2, callback2); + + callback2.eventuallyExpect(Event.AVAILABLE, JOIN_TIMEOUT_MILLIS); + } + + @Test + public void unregisterNetworkCallback_threadIsStopped() throws Exception { + grantPermissions(THREAD_NETWORK_PRIVILEGED, ACCESS_FINE_LOCATION); + var request = newThreadNetworkRequest(TEST_DATASET_1, true /* createPartitionIfNotFound */); + var callback = newNetworkCallback(); + cm.requestNetwork(request, callback); + callback.eventuallyExpect(Event.AVAILABLE, JOIN_TIMEOUT_MILLIS); + + cm.unregisterNetworkCallback(callback); + mCallbacks.remove(callback); + + dropAllPermissions(); + mController.waitForRole(DEVICE_ROLE_STOPPED, STOP_TIMEOUT); + } +} diff --git a/thread/tests/unit/AndroidManifest.xml b/thread/tests/unit/AndroidManifest.xml index 8442e804e6..c6f67deef1 100644 --- a/thread/tests/unit/AndroidManifest.xml +++ b/thread/tests/unit/AndroidManifest.xml @@ -20,6 +20,7 @@ package="android.net.thread.unittests"> + 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 51f20e15d6..c52abf38a7 100644 --- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java +++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java @@ -179,8 +179,11 @@ public final class ThreadNetworkControllerServiceTest { private static final String TEST_VENDOR_NAME = "test vendor"; private static final String TEST_MODEL_NAME = "test model"; private static final LinkAddress TEST_NAT64_CIDR = new LinkAddress("192.168.255.0/24"); + private static final int THREAD_NETWORK_PROVIDER_ID = 1; @Mock private MockableSystemProperties mMockSystemProperties; + @Mock private ThreadNetworkFactory mMockNetworkFactory; + @Mock private NetworkProvider mMockNetworkProvider; @Mock private ConnectivityManager mMockConnectivityManager; @Mock private RoutingCoordinatorManager mMockRoutingCoordinatorManager; @Mock private NetworkAgent mMockNetworkAgent; @@ -233,8 +236,12 @@ public final class ThreadNetworkControllerServiceTest { mTestLooper = new TestLooper(); final Handler handler = new Handler(mTestLooper.getLooper()); - NetworkProvider networkProvider = - new NetworkProvider(mContext, mTestLooper.getLooper(), "ThreadNetworkProvider"); + + when(mMockNetworkFactory.getProvider()).thenReturn(mMockNetworkProvider); + doNothing().when(mMockNetworkFactory).register(); + + when(mMockConnectivityManager.registerNetworkProvider(any(NetworkProvider.class))) + .thenReturn(THREAD_NETWORK_PROVIDER_ID); when(mMockRoutingCoordinatorManager.requestDownstreamAddress(any())) .thenReturn(TEST_NAT64_CIDR); @@ -272,7 +279,7 @@ public final class ThreadNetworkControllerServiceTest { mContext, handler, mMockSystemProperties, - networkProvider, + mMockNetworkFactory, () -> mFakeOtDaemon, mMockConnectivityManager, mMockRoutingCoordinatorManager, diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkFactoryTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkFactoryTest.java new file mode 100644 index 0000000000..931c18fdc3 --- /dev/null +++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkFactoryTest.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2025 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 com.google.common.io.BaseEncoding.base16; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import android.content.Context; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.thread.ActiveOperationalDataset; +import android.net.thread.ThreadNetworkSpecifier; +import android.os.test.TestLooper; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** Unit tests for {@link ThreadNetworkFactory}. */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public final class ThreadNetworkFactoryTest { + // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new": + // Active Timestamp: 1 + // Channel: 19 + // Channel Mask: 0x07FFF800 + // Ext PAN ID: ACC214689BC40BDF + // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64 + // Network Key: F26B3153760F519A63BAFDDFFC80D2AF + // Network Name: OpenThread-d9a0 + // PAN ID: 0xD9A0 + // PSKc: A245479C836D551B9CA557F7B9D351B4 + // Security Policy: 672 onrcb + private static final byte[] DATASET_TLVS = + base16().decode( + "0E080000000000010000000300001335060004001FFFE002" + + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31" + + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561" + + "642D643961300102D9A00410A245479C836D551B9CA557F7" + + "B9D351B40C0402A0FFF8"); + private static final ActiveOperationalDataset ACTIVE_DATASET = + ActiveOperationalDataset.fromThreadTlvs(DATASET_TLVS); + + private Context mContext; + private TestLooper mTestLooper; + @Mock private ThreadNetworkControllerService mMockControllerService; + + private ThreadNetworkFactory mFactory; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mContext = ApplicationProvider.getApplicationContext(); + mTestLooper = new TestLooper(); + mFactory = new ThreadNetworkFactory(mContext, mTestLooper.getLooper()); + mFactory.initialize(mMockControllerService); + } + + @Test + public void needNetworkFor_noThreadTransport_doNothing() { + NetworkRequest request = + new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build(); + + mFactory.needNetworkFor(request); + + verifyNoInteractions(mMockControllerService); + } + + @Test + public void needNetworkFor_threadTransportAndNoSpecifier_doNothing() { + NetworkRequest request = + new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_THREAD) + .build(); + + mFactory.needNetworkFor(request); + + verifyNoInteractions(mMockControllerService); + } + + @Test + public void needNetworkFor_threadTransportAndSpecifier_joinIsCalled() { + ThreadNetworkSpecifier specifier = + new ThreadNetworkSpecifier.Builder() + .setActiveOperationalDataset(ACTIVE_DATASET) + .build(); + NetworkRequest request = + new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_THREAD) + .setNetworkSpecifier(specifier) + .build(); + + mFactory.needNetworkFor(request); + + verify(mMockControllerService).joinWithSpecifier(eq(specifier), any()); + } + + @Test + public void needNetworkFor_specifierWithNoDataset_doNothing() { + NetworkRequest request = + new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_THREAD) + .setNetworkSpecifier(new ThreadNetworkSpecifier.Builder().build()) + .build(); + + mFactory.needNetworkFor(request); + + verifyNoInteractions(mMockControllerService); + } +} diff --git a/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java index c117975fc7..ba94289977 100644 --- a/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java +++ b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java @@ -17,8 +17,8 @@ package android.net.thread.utils; import static android.Manifest.permission.MANAGE_TEST_NETWORKS; -import static com.android.testutils.TestableNetworkCallback.Event.LINK_PROPERTIES_CHANGED; import static com.android.testutils.TestPermissionUtil.runAsShell; +import static com.android.testutils.TestableNetworkCallback.Event.LINK_PROPERTIES_CHANGED; import android.content.Context; import android.net.ConnectivityManager; diff --git a/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java index c4150cb103..a6efc08cb1 100644 --- a/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java +++ b/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java @@ -189,7 +189,7 @@ public final class ThreadNetworkControllerWrapper { try { return future.get(timeout.toSeconds(), SECONDS); } finally { - mController.unregisterStateCallback(callback); + runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback)); } } @@ -230,6 +230,12 @@ public final class ThreadNetworkControllerWrapper { future.get(CONFIG_TIMEOUT.toSeconds(), SECONDS); } + public void setBorderRouterEnabledAndWait(boolean enabled) throws Exception { + final ThreadConfiguration config = + new ThreadConfiguration.Builder().setBorderRouterEnabled(enabled).build(); + setConfigurationAndWait(config); + } + public void setNat64EnabledAndWait(boolean enabled) throws Exception { final ThreadConfiguration config = getConfiguration(); final ThreadConfiguration newConfig =