From b1c75dbe8c35f09a10fe67ca2ff7559aacb2ade2 Mon Sep 17 00:00:00 2001 From: Kangping Dong Date: Wed, 30 Aug 2023 19:15:04 +0800 Subject: [PATCH] [Thread] add ThreadNetworkController APIs The ThreadNetworkController class provides APIs for managing the Thread network, for example, attach to a specific network, form a network with given dataset or update/migrate an existing network. Bug: 262683651 Test: atest CtsThreadNetworkTestCases Change-Id: Ib3c267d2c81a8c3c7772ed3c9cd2092487cc941a --- Tethering/apex/Android.bp | 1 + framework-t/api/system-current.txt | 38 + .../IActiveOperationalDatasetReceiver.aidl | 25 + .../net/thread/IOperationReceiver.aidl | 23 + .../thread/IOperationalDatasetCallback.aidl | 28 + .../thread/IScheduleMigrationReceiver.aidl | 24 + .../android/net/thread/IStateCallback.aidl | 25 + .../net/thread/IThreadNetworkController.aidl | 18 + .../net/thread/ThreadNetworkController.java | 413 +++++++++- .../net/thread/ThreadNetworkException.java | 137 ++++ .../net/thread/ThreadNetworkManager.java | 13 + thread/service/Android.bp | 24 + .../thread/OperationReceiverWrapper.java | 82 ++ .../ThreadNetworkControllerService.java | 720 +++++++++++++++++- .../server/thread/ThreadNetworkService.java | 17 +- .../server/thread/TunInterfaceController.java | 140 ++++ ...d_server_thread_TunInterfaceController.cpp | 124 +++ thread/service/jni/onload.cpp | 37 + .../cts/ThreadNetworkControllerTest.java | 522 ++++++++++++- 19 files changed, 2388 insertions(+), 23 deletions(-) create mode 100644 thread/framework/java/android/net/thread/IActiveOperationalDatasetReceiver.aidl create mode 100644 thread/framework/java/android/net/thread/IOperationReceiver.aidl create mode 100644 thread/framework/java/android/net/thread/IOperationalDatasetCallback.aidl create mode 100644 thread/framework/java/android/net/thread/IScheduleMigrationReceiver.aidl create mode 100644 thread/framework/java/android/net/thread/IStateCallback.aidl create mode 100644 thread/framework/java/android/net/thread/ThreadNetworkException.java create mode 100644 thread/service/java/com/android/server/thread/OperationReceiverWrapper.java create mode 100644 thread/service/java/com/android/server/thread/TunInterfaceController.java create mode 100644 thread/service/jni/com_android_server_thread_TunInterfaceController.cpp create mode 100644 thread/service/jni/onload.cpp diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp index ee44f3ccb4..de9017ae57 100644 --- a/Tethering/apex/Android.bp +++ b/Tethering/apex/Android.bp @@ -80,6 +80,7 @@ apex { first: { jni_libs: [ "libservice-connectivity", + "libservice-thread-jni", "libandroid_net_connectivity_com_android_net_module_util_jni", ], native_shared_libs: [ diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt index 23510e1fa5..08c2e660e3 100644 --- a/framework-t/api/system-current.txt +++ b/framework-t/api/system-current.txt @@ -494,9 +494,47 @@ package android.net.thread { @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkController { method public int getThreadVersion(); + method public static boolean isAttached(int); + method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void join(@NonNull android.net.thread.ActiveOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver); + method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void leave(@NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver); + method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void registerOperationalDatasetCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback); + method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerStateCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.StateCallback); + method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void scheduleMigration(@NonNull android.net.thread.PendingOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver); + method public void unregisterOperationalDatasetCallback(@NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback); + method public void unregisterStateCallback(@NonNull android.net.thread.ThreadNetworkController.StateCallback); + field public static final int DEVICE_ROLE_CHILD = 2; // 0x2 + field public static final int DEVICE_ROLE_DETACHED = 1; // 0x1 + field public static final int DEVICE_ROLE_LEADER = 4; // 0x4 + field public static final int DEVICE_ROLE_ROUTER = 3; // 0x3 + field public static final int DEVICE_ROLE_STOPPED = 0; // 0x0 field public static final int THREAD_VERSION_1_3 = 4; // 0x4 } + public static interface ThreadNetworkController.OperationalDatasetCallback { + method public void onActiveOperationalDatasetChanged(@Nullable android.net.thread.ActiveOperationalDataset); + method public default void onPendingOperationalDatasetChanged(@Nullable android.net.thread.PendingOperationalDataset); + } + + public static interface ThreadNetworkController.StateCallback { + method public void onDeviceRoleChanged(int); + method public default void onPartitionIdChanged(long); + } + + @FlaggedApi("com.android.net.thread.flags.thread_enabled") public class ThreadNetworkException extends java.lang.Exception { + ctor public ThreadNetworkException(int, @NonNull String); + method public int getErrorCode(); + field public static final int ERROR_ABORTED = 2; // 0x2 + field public static final int ERROR_BUSY = 5; // 0x5 + field public static final int ERROR_FAILED_PRECONDITION = 6; // 0x6 + field public static final int ERROR_INTERNAL_ERROR = 1; // 0x1 + field public static final int ERROR_REJECTED_BY_PEER = 8; // 0x8 + field public static final int ERROR_RESOURCE_EXHAUSTED = 10; // 0xa + field public static final int ERROR_RESPONSE_BAD_FORMAT = 9; // 0x9 + field public static final int ERROR_TIMEOUT = 3; // 0x3 + field public static final int ERROR_UNAVAILABLE = 4; // 0x4 + field public static final int ERROR_UNSUPPORTED_CHANNEL = 7; // 0x7 + } + @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkManager { method @NonNull public java.util.List getAllThreadNetworkControllers(); } diff --git a/thread/framework/java/android/net/thread/IActiveOperationalDatasetReceiver.aidl b/thread/framework/java/android/net/thread/IActiveOperationalDatasetReceiver.aidl new file mode 100644 index 0000000000..aba54ebe1e --- /dev/null +++ b/thread/framework/java/android/net/thread/IActiveOperationalDatasetReceiver.aidl @@ -0,0 +1,25 @@ +/* + * Copyright 2023 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 android.net.thread.ActiveOperationalDataset; + +/** Receives the result of an operation which returns an Active Operational Dataset. @hide */ +oneway interface IActiveOperationalDatasetReceiver { + void onSuccess(in ActiveOperationalDataset dataset); + void onError(int errorCode, String errorMessage); +} diff --git a/thread/framework/java/android/net/thread/IOperationReceiver.aidl b/thread/framework/java/android/net/thread/IOperationReceiver.aidl new file mode 100644 index 0000000000..42e157b009 --- /dev/null +++ b/thread/framework/java/android/net/thread/IOperationReceiver.aidl @@ -0,0 +1,23 @@ +/* + * Copyright 2023 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; + +/** Receives the result of a Thread network operation. @hide */ +oneway interface IOperationReceiver { + void onSuccess(); + void onError(int errorCode, String errorMessage); +} diff --git a/thread/framework/java/android/net/thread/IOperationalDatasetCallback.aidl b/thread/framework/java/android/net/thread/IOperationalDatasetCallback.aidl new file mode 100644 index 0000000000..b576b331ca --- /dev/null +++ b/thread/framework/java/android/net/thread/IOperationalDatasetCallback.aidl @@ -0,0 +1,28 @@ +/* + * Copyright 2023 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 android.net.thread.ActiveOperationalDataset; +import android.net.thread.PendingOperationalDataset; + +/** + * @hide + */ +oneway interface IOperationalDatasetCallback { + void onActiveOperationalDatasetChanged(in @nullable ActiveOperationalDataset activeOpDataset); + void onPendingOperationalDatasetChanged(in @nullable PendingOperationalDataset pendingOpDataset); +} diff --git a/thread/framework/java/android/net/thread/IScheduleMigrationReceiver.aidl b/thread/framework/java/android/net/thread/IScheduleMigrationReceiver.aidl new file mode 100644 index 0000000000..c45d4638c7 --- /dev/null +++ b/thread/framework/java/android/net/thread/IScheduleMigrationReceiver.aidl @@ -0,0 +1,24 @@ +/* + * Copyright 2023 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; + +/** Receives the result of {@link ThreadNetworkManager#scheduleMigration}. @hide */ +oneway interface IScheduleMigrationReceiver { + void onScheduled(long delayTimerMillis); + void onMigrated(); + void onError(int errorCode, String errorMessage); +} diff --git a/thread/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl new file mode 100644 index 0000000000..d7cbda9f70 --- /dev/null +++ b/thread/framework/java/android/net/thread/IStateCallback.aidl @@ -0,0 +1,25 @@ +/* + * Copyright 2023 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; + +/** + * @hide + */ +oneway interface IStateCallback { + void onDeviceRoleChanged(int deviceRole); + void onPartitionIdChanged(long partitionId); +} diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl index 0219beb524..0e62b0b16f 100644 --- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl +++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl @@ -16,10 +16,28 @@ package android.net.thread; +import android.net.Network; +import android.net.thread.ActiveOperationalDataset; +import android.net.thread.IActiveOperationalDatasetReceiver; +import android.net.thread.IOperationalDatasetCallback; +import android.net.thread.IOperationReceiver; +import android.net.thread.IScheduleMigrationReceiver; +import android.net.thread.IStateCallback; +import android.net.thread.PendingOperationalDataset; + /** * Interface for communicating with ThreadNetworkControllerService. * @hide */ interface IThreadNetworkController { + void registerStateCallback(in IStateCallback callback); + void unregisterStateCallback(in IStateCallback callback); + void registerOperationalDatasetCallback(in IOperationalDatasetCallback callback); + void unregisterOperationalDatasetCallback(in IOperationalDatasetCallback callback); + + void join(in ActiveOperationalDataset activeOpDataset, in IOperationReceiver receiver); + void scheduleMigration(in PendingOperationalDataset pendingOpDataset, in IOperationReceiver receiver); + void leave(in IOperationReceiver receiver); + int getThreadVersion(); } diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java index fc77b1a243..9d6a2574a9 100644 --- a/thread/framework/java/android/net/thread/ThreadNetworkController.java +++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java @@ -18,23 +18,64 @@ package android.net.thread; import static java.util.Objects.requireNonNull; +import android.Manifest.permission; +import android.annotation.CallbackExecutor; import android.annotation.FlaggedApi; import android.annotation.IntDef; import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.RequiresPermission; import android.annotation.SystemApi; +import android.os.OutcomeReceiver; import android.os.RemoteException; +import com.android.internal.annotations.GuardedBy; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executor; /** - * Provides the primary API for controlling all aspects of a Thread network. + * Provides the primary APIs for controlling all aspects of a Thread network. + * + *

For example, join this device to a Thread network with given Thread Operational Dataset, or + * migrate an existing network. * * @hide */ @FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED) @SystemApi public final class ThreadNetworkController { + private static final String TAG = "ThreadNetworkController"; + + /** The Thread stack is stopped. */ + public static final int DEVICE_ROLE_STOPPED = 0; + + /** The device is not currently participating in a Thread network/partition. */ + public static final int DEVICE_ROLE_DETACHED = 1; + + /** The device is a Thread Child. */ + public static final int DEVICE_ROLE_CHILD = 2; + + /** The device is a Thread Router. */ + public static final int DEVICE_ROLE_ROUTER = 3; + + /** The device is a Thread Leader. */ + public static final int DEVICE_ROLE_LEADER = 4; + + /** @hide */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + DEVICE_ROLE_STOPPED, + DEVICE_ROLE_DETACHED, + DEVICE_ROLE_CHILD, + DEVICE_ROLE_ROUTER, + DEVICE_ROLE_LEADER + }) + public @interface DeviceRole {} /** Thread standard version 1.3. */ public static final int THREAD_VERSION_1_3 = 4; @@ -46,9 +87,19 @@ public final class ThreadNetworkController { private final IThreadNetworkController mControllerService; + private final Object mStateCallbackMapLock = new Object(); + + @GuardedBy("mStateCallbackMapLock") + private final Map mStateCallbackMap = new HashMap<>(); + + private final Object mOpDatasetCallbackMapLock = new Object(); + + @GuardedBy("mOpDatasetCallbackMapLock") + private final Map + mOpDatasetCallbackMap = new HashMap<>(); + ThreadNetworkController(@NonNull IThreadNetworkController controllerService) { requireNonNull(controllerService, "controllerService cannot be null"); - mControllerService = controllerService; } @@ -61,4 +112,362 @@ public final class ThreadNetworkController { throw e.rethrowFromSystemServer(); } } + + /** Returns {@code true} if {@code deviceRole} indicates an attached state. */ + public static boolean isAttached(@DeviceRole int deviceRole) { + return deviceRole == DEVICE_ROLE_CHILD + || deviceRole == DEVICE_ROLE_ROUTER + || deviceRole == DEVICE_ROLE_LEADER; + } + + /** + * Callback to receive notifications when the Thread network states are changed. + * + *

Applications which are interested in monitoring Thread network states should implement + * this interface and register the callback with {@link #registerStateCallback}. + */ + public interface StateCallback { + /** + * The Thread device role has changed. + * + * @param deviceRole the new Thread device role + */ + void onDeviceRoleChanged(@DeviceRole int deviceRole); + + /** + * The Thread network partition ID has changed. + * + * @param partitionId the new Thread partition ID + */ + default void onPartitionIdChanged(long partitionId) {} + } + + private static final class StateCallbackProxy extends IStateCallback.Stub { + private final Executor mExecutor; + private final StateCallback mCallback; + + StateCallbackProxy(@CallbackExecutor Executor executor, StateCallback callback) { + mExecutor = executor; + mCallback = callback; + } + + @Override + public void onDeviceRoleChanged(@DeviceRole int deviceRole) { + mExecutor.execute(() -> mCallback.onDeviceRoleChanged(deviceRole)); + } + + @Override + public void onPartitionIdChanged(long partitionId) { + mExecutor.execute(() -> mCallback.onPartitionIdChanged(partitionId)); + } + } + + /** + * Registers a callback to be called when Thread network states are changed. + * + *

Upon return of this method, methods of {@code callback} will be invoked immediately with + * existing states. + * + * @param executor the executor to execute the {@code callback} + * @param callback the callback to receive Thread network state changes + * @throws IllegalArgumentException if {@code callback} has already been registered + */ + @RequiresPermission(permission.ACCESS_NETWORK_STATE) + public void registerStateCallback( + @NonNull @CallbackExecutor Executor executor, @NonNull StateCallback callback) { + requireNonNull(executor, "executor cannot be null"); + requireNonNull(callback, "callback cannot be null"); + synchronized (mStateCallbackMapLock) { + if (mStateCallbackMap.containsKey(callback)) { + throw new IllegalArgumentException("callback has already been registered"); + } + StateCallbackProxy callbackProxy = new StateCallbackProxy(executor, callback); + mStateCallbackMap.put(callback, callbackProxy); + + try { + mControllerService.registerStateCallback(callbackProxy); + } catch (RemoteException e) { + mStateCallbackMap.remove(callback); + e.rethrowFromSystemServer(); + } + } + } + + /** + * Unregisters the Thread state changed callback. + * + * @param callback the callback which has been registered with {@link #registerStateCallback} + * @throws IllegalArgumentException if {@code callback} hasn't been registered + */ + public void unregisterStateCallback(@NonNull StateCallback callback) { + requireNonNull(callback, "callback cannot be null"); + synchronized (mStateCallbackMapLock) { + StateCallbackProxy callbackProxy = mStateCallbackMap.remove(callback); + if (callbackProxy == null) { + throw new IllegalArgumentException("callback hasn't been registered"); + } + try { + mControllerService.unregisterStateCallback(callbackProxy); + } catch (RemoteException e) { + e.rethrowFromSystemServer(); + } + } + } + + /** + * Callback to receive notifications when the Thread Operational Datasets are changed. + * + *

Applications which are interested in monitoring Thread network datasets should implement + * this interface and register the callback with {@link #registerOperationalDatasetCallback}. + */ + public interface OperationalDatasetCallback { + /** + * Called when the Active Operational Dataset is changed. + * + * @param activeDataset the new Active Operational Dataset or {@code null} if the dataset is + * absent + */ + void onActiveOperationalDatasetChanged(@Nullable ActiveOperationalDataset activeDataset); + + /** + * Called when the Pending Operational Dataset is changed. + * + * @param pendingDataset the new Pending Operational Dataset or {@code null} if the dataset + * has been committed and removed + */ + default void onPendingOperationalDatasetChanged( + @Nullable PendingOperationalDataset pendingDataset) {} + } + + private static final class OperationalDatasetCallbackProxy + extends IOperationalDatasetCallback.Stub { + private final Executor mExecutor; + private final OperationalDatasetCallback mCallback; + + OperationalDatasetCallbackProxy( + @CallbackExecutor Executor executor, OperationalDatasetCallback callback) { + mExecutor = executor; + mCallback = callback; + } + + @Override + public void onActiveOperationalDatasetChanged( + @Nullable ActiveOperationalDataset activeDataset) { + mExecutor.execute(() -> mCallback.onActiveOperationalDatasetChanged(activeDataset)); + } + + @Override + public void onPendingOperationalDatasetChanged( + @Nullable PendingOperationalDataset pendingDataset) { + mExecutor.execute(() -> mCallback.onPendingOperationalDatasetChanged(pendingDataset)); + } + } + + /** + * Registers a callback to be called when Thread Operational Datasets are changed. + * + *

Upon return of this method, methods of {@code callback} will be invoked immediately with + * existing Operational Datasets. + * + * @param executor the executor to execute {@code callback} + * @param callback the callback to receive Operational Dataset changes + * @throws IllegalArgumentException if {@code callback} has already been registered + */ + @RequiresPermission( + allOf = { + permission.ACCESS_NETWORK_STATE, + "android.permission.THREAD_NETWORK_PRIVILEGED" + }) + public void registerOperationalDatasetCallback( + @NonNull @CallbackExecutor Executor executor, + @NonNull OperationalDatasetCallback callback) { + requireNonNull(executor, "executor cannot be null"); + requireNonNull(callback, "callback cannot be null"); + synchronized (mOpDatasetCallbackMapLock) { + if (mOpDatasetCallbackMap.containsKey(callback)) { + throw new IllegalArgumentException("callback has already been registered"); + } + OperationalDatasetCallbackProxy callbackProxy = + new OperationalDatasetCallbackProxy(executor, callback); + mOpDatasetCallbackMap.put(callback, callbackProxy); + + try { + mControllerService.registerOperationalDatasetCallback(callbackProxy); + } catch (RemoteException e) { + mOpDatasetCallbackMap.remove(callback); + e.rethrowFromSystemServer(); + } + } + } + + /** + * Unregisters the Thread Operational Dataset callback. + * + * @param callback the callback which has been registered with {@link + * #registerOperationalDatasetCallback} + * @throws IllegalArgumentException if {@code callback} hasn't been registered + */ + public void unregisterOperationalDatasetCallback(@NonNull OperationalDatasetCallback callback) { + requireNonNull(callback, "callback cannot be null"); + synchronized (mOpDatasetCallbackMapLock) { + OperationalDatasetCallbackProxy callbackProxy = mOpDatasetCallbackMap.remove(callback); + if (callbackProxy == null) { + throw new IllegalArgumentException("callback hasn't been registered"); + } + try { + mControllerService.unregisterOperationalDatasetCallback(callbackProxy); + } catch (RemoteException e) { + e.rethrowFromSystemServer(); + } + } + } + + /** + * Joins to a Thread network with given Active Operational Dataset. + * + *

This method does nothing if this device has already joined to the same network specified + * by {@code activeDataset}. If this device has already joined to a different network, this + * device will first leave from that network and then join the new network. This method changes + * only this device and all other connected devices will stay in the old network. To change the + * network for all connected devices together, use {@link #scheduleMigration}. + * + *

On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called and the Dataset + * will be persisted on this device; this device will try to attach to the Thread network and + * the state changes can be observed by {@link #registerStateCallback}. On failure, {@link + * OutcomeReceiver#onError} of {@code receiver} will be invoked with a specific error: + * + *

    + *
  • {@link ThreadNetworkException#ERROR_UNSUPPORTED_CHANNEL} {@code activeDataset} + * specifies a channel which is not supported in the current country or region; the {@code + * activeDataset} is rejected and not persisted so this device won't auto re-join the next + * time + *
  • {@link ThreadNetworkException#ERROR_ABORTED} this operation is aborted by another + * {@code join} or {@code leave} operation + *
+ * + * @param activeDataset the Active Operational Dataset represents the Thread network to join + * @param executor the executor to execute {@code receiver} + * @param receiver the receiver to receive result of this operation + */ + @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") + public void join( + @NonNull ActiveOperationalDataset activeDataset, + @NonNull @CallbackExecutor Executor executor, + @NonNull OutcomeReceiver receiver) { + requireNonNull(activeDataset, "activeDataset cannot be null"); + requireNonNull(executor, "executor cannot be null"); + requireNonNull(receiver, "receiver cannot be null"); + try { + mControllerService.join(activeDataset, new OperationReceiverProxy(executor, receiver)); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Schedules a network migration which moves all devices in the current connected network to a + * new network or updates parameters of the current connected network. + * + *

The migration doesn't happen immediately but is registered to the Leader device so that + * all devices in the current Thread network can be scheduled to apply the new dataset together. + * + *

On success, the Pending Dataset is successfully registered and persisted on the Leader and + * {@link OutcomeReceiver#onResult} of {@code receiver} will be called; Operational Dataset + * changes will be asynchronously delivered via {@link OperationalDatasetCallback} if a callback + * has been registered with {@link #registerOperationalDatasetCallback}. When failed, {@link + * OutcomeReceiver#onError} will be called with a specific error: + * + *

    + *
  • {@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} the migration is rejected + * because this device is not attached + *
  • {@link ThreadNetworkException#ERROR_UNSUPPORTED_CHANNEL} {@code pendingDataset} + * specifies a channel which is not supported in the current country or region; the {@code + * pendingDataset} is rejected and not persisted + *
  • {@link ThreadNetworkException#ERROR_REJECTED_BY_PEER} the Pending Dataset is rejected + * by the Leader device + *
  • {@link ThreadNetworkException#ERROR_BUSY} another {@code scheduleMigration} request is + * being processed + *
  • {@link ThreadNetworkException#ERROR_TIMEOUT} response from the Leader device hasn't + * been received before deadline + *
+ * + *

The Delay Timer of {@code pendingDataset} can vary from several minutes to a few days. + * It's important to select a proper value to safely migrate all devices in the network without + * leaving sleepy end devices orphaned. Apps are not suggested to specify the Delay Timer value + * if it's unclear how long it can take to propagate the {@code pendingDataset} to the whole + * network. Instead, use {@link Duration#ZERO} to use the default value suggested by the system. + * + * @param pendingDataset the Pending Operational Dataset + * @param executor the executor to execute {@code receiver} + * @param receiver the receiver to receive result of this operation + */ + @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") + public void scheduleMigration( + @NonNull PendingOperationalDataset pendingDataset, + @NonNull @CallbackExecutor Executor executor, + @NonNull OutcomeReceiver receiver) { + requireNonNull(pendingDataset, "pendingDataset cannot be null"); + requireNonNull(executor, "executor cannot be null"); + requireNonNull(receiver, "receiver cannot be null"); + try { + mControllerService.scheduleMigration( + pendingDataset, new OperationReceiverProxy(executor, receiver)); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Leaves from the Thread network. + * + *

This undoes a {@link join} operation. On success, this device is disconnected from the + * joined network and will not automatically join a network before {@link #join} is called + * again. Active and Pending Operational Dataset configured and persisted on this device will be + * removed too. + * + * @param executor the executor to execute {@code receiver} + * @param receiver the receiver to receive result of this operation + */ + @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") + public void leave( + @NonNull @CallbackExecutor Executor executor, + @NonNull OutcomeReceiver receiver) { + requireNonNull(executor, "executor cannot be null"); + requireNonNull(receiver, "receiver cannot be null"); + try { + mControllerService.leave(new OperationReceiverProxy(executor, receiver)); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + private static void propagateError( + Executor executor, + OutcomeReceiver receiver, + int errorCode, + String errorMsg) { + executor.execute(() -> receiver.onError(new ThreadNetworkException(errorCode, errorMsg))); + } + + private static final class OperationReceiverProxy extends IOperationReceiver.Stub { + final Executor mExecutor; + final OutcomeReceiver mResultReceiver; + + OperationReceiverProxy( + @CallbackExecutor Executor executor, + OutcomeReceiver resultReceiver) { + this.mExecutor = executor; + this.mResultReceiver = resultReceiver; + } + + @Override + public void onSuccess() { + mExecutor.execute(() -> mResultReceiver.onResult(null)); + } + + @Override + public void onError(int errorCode, String errorMessage) { + propagateError(mExecutor, mResultReceiver, errorCode, errorMessage); + } + } } diff --git a/thread/framework/java/android/net/thread/ThreadNetworkException.java b/thread/framework/java/android/net/thread/ThreadNetworkException.java new file mode 100644 index 0000000000..c5e1e971a9 --- /dev/null +++ b/thread/framework/java/android/net/thread/ThreadNetworkException.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2023 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 java.util.Objects.requireNonNull; + +import android.annotation.FlaggedApi; +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.SystemApi; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Represents a Thread network specific failure. + * + * @hide + */ +@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED) +@SystemApi +public class ThreadNetworkException extends Exception { + /** @hide */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ERROR_INTERNAL_ERROR, + ERROR_ABORTED, + ERROR_TIMEOUT, + ERROR_UNAVAILABLE, + ERROR_BUSY, + ERROR_FAILED_PRECONDITION, + ERROR_UNSUPPORTED_CHANNEL, + ERROR_REJECTED_BY_PEER, + ERROR_RESPONSE_BAD_FORMAT, + ERROR_RESOURCE_EXHAUSTED, + }) + public @interface ErrorCode {} + + /** + * The operation failed because some invariants expected by the underlying system have been + * broken. This error code is reserved for serious errors. The caller can do nothing to recover + * from this error. A bugreport should be created and sent to the Android community if this + * error is ever returned. + */ + public static final int ERROR_INTERNAL_ERROR = 1; + + /** + * The operation failed because concurrent operations are overriding this one. Retrying an + * aborted operation has the risk of aborting another ongoing operation again. So the caller + * should retry at a higher level where it knows there won't be race conditions. + */ + public static final int ERROR_ABORTED = 2; + + /** + * The operation failed because a deadline expired before the operation could complete. This may + * be caused by connectivity unavailability and the caller can retry the same operation when the + * connectivity issue is fixed. + */ + public static final int ERROR_TIMEOUT = 3; + + /** + * The operation failed because the service is currently unavailable and that this is most + * likely a transient condition. The caller can recover from this error by retrying with a + * back-off scheme. Note that it is not always safe to retry non-idempotent operations. + */ + public static final int ERROR_UNAVAILABLE = 4; + + /** + * The operation failed because this device is currently busy processing concurrent requests. + * The caller may recover from this error when the current operations has been finished. + */ + public static final int ERROR_BUSY = 5; + + /** + * The operation failed because required preconditions were not satisfied. For example, trying + * to schedule a network migration when this device is not attached will receive this error. The + * caller should not retry the same operation before the precondition is satisfied. + */ + public static final int ERROR_FAILED_PRECONDITION = 6; + + /** + * The operation was rejected because the specified channel is currently not supported by this + * device in this country. For example, trying to join or migrate to a network with channel + * which is not supported. The caller should should change the channel or return an error to the + * user if the channel cannot be changed. + */ + public static final int ERROR_UNSUPPORTED_CHANNEL = 7; + + /** + * The operation failed because a request is rejected by the peer device. This happens because + * the peer device is not capable of processing the request, or a request from another device + * has already been accepted by the peer device. The caller may not be able to recover from this + * error by retrying the same operation. + */ + public static final int ERROR_REJECTED_BY_PEER = 8; + + /** + * The operation failed because the received response is malformed. This is typically because + * the peer device is misbehaving. The caller may only recover from this error by retrying with + * a different peer device. + */ + public static final int ERROR_RESPONSE_BAD_FORMAT = 9; + + /** + * The operation failed because some resource has been exhausted. For example, no enough + * allocated memory buffers, or maximum number of supported operations has been exceeded. The + * caller may retry and recover from this error when the resource has been freed. + */ + public static final int ERROR_RESOURCE_EXHAUSTED = 10; + + private final int mErrorCode; + + /** Creates a new {@link ThreadNetworkException} object with given error code and message. */ + public ThreadNetworkException(@ErrorCode int errorCode, @NonNull String errorMessage) { + super(requireNonNull(errorMessage, "errorMessage cannot be null")); + this.mErrorCode = errorCode; + } + + /** Returns the error code. */ + public @ErrorCode int getErrorCode() { + return mErrorCode; + } +} diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java index c3bdbd7ff4..28012a7587 100644 --- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java +++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java @@ -66,6 +66,19 @@ public final class ThreadNetworkManager { */ public static final String FEATURE_NAME = "android.hardware.thread_network"; + /** + * Permission allows changing Thread network state and access to Thread network credentials such + * as Network Key and PSKc. + * + *

This is the same value as android.Manifest.permission.THREAD_NETWORK_PRIVILEGED. That + * symbol is not available on U while this feature needs to support Android U TV devices, so + * here is making a copy of android.Manifest.permission.THREAD_NETWORK_PRIVILEGED. + * + * @hide + */ + public static final String PERMISSION_THREAD_NETWORK_PRIVILEGED = + "android.permission.THREAD_NETWORK_PRIVILEGED"; + @NonNull private final Context mContext; @NonNull private final List mUnmodifiableControllerServices; diff --git a/thread/service/Android.bp b/thread/service/Android.bp index fcf9521807..bd265e60c1 100644 --- a/thread/service/Android.bp +++ b/thread/service/Android.bp @@ -33,11 +33,35 @@ java_library { min_sdk_version: "30", srcs: [":service-thread-sources"], libs: [ + "framework-connectivity-pre-jarjar", "framework-connectivity-t-pre-jarjar", "service-connectivity-pre-jarjar", ], static_libs: [ "net-utils-device-common", + "net-utils-device-common-netlink", + "ot-daemon-aidl-java", + ], + apex_available: ["com.android.tethering"], +} + +cc_library_shared { + name: "libservice-thread-jni", + min_sdk_version: "30", + cflags: [ + "-Wall", + "-Werror", + "-Wno-unused-parameter", + "-Wthread-safety", + ], + srcs: [ + "jni/**/*.cpp", + ], + shared_libs: [ + "libbase", + "libcutils", + "liblog", + "libnativehelper", ], apex_available: ["com.android.tethering"], } diff --git a/thread/service/java/com/android/server/thread/OperationReceiverWrapper.java b/thread/service/java/com/android/server/thread/OperationReceiverWrapper.java new file mode 100644 index 0000000000..a8909bcd79 --- /dev/null +++ b/thread/service/java/com/android/server/thread/OperationReceiverWrapper.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2023 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.thread.ThreadNetworkException.ERROR_UNAVAILABLE; + +import android.net.thread.IOperationReceiver; +import android.os.RemoteException; + +import com.android.internal.annotations.GuardedBy; + +import java.util.HashSet; +import java.util.Set; + +/** A {@link IOperationReceiver} wrapper which makes it easier to invoke the callbacks. */ +final class OperationReceiverWrapper { + private final IOperationReceiver mReceiver; + + private static final Object sPendingReceiversLock = new Object(); + + @GuardedBy("sPendingReceiversLock") + private static final Set sPendingReceivers = new HashSet<>(); + + public OperationReceiverWrapper(IOperationReceiver receiver) { + this.mReceiver = receiver; + + synchronized (sPendingReceiversLock) { + sPendingReceivers.add(this); + } + } + + public static void onOtDaemonDied() { + synchronized (sPendingReceiversLock) { + for (OperationReceiverWrapper receiver : sPendingReceivers) { + try { + receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died"); + } catch (RemoteException e) { + // The client is dead, do nothing + } + } + sPendingReceivers.clear(); + } + } + + public void onSuccess() { + synchronized (sPendingReceiversLock) { + sPendingReceivers.remove(this); + } + + try { + mReceiver.onSuccess(); + } catch (RemoteException e) { + // The client is dead, do nothing + } + } + + public void onError(int errorCode, String errorMessage, Object... messageArgs) { + synchronized (sPendingReceiversLock) { + sPendingReceivers.remove(this); + } + + try { + mReceiver.onError(errorCode, String.format(errorMessage, messageArgs)); + } catch (RemoteException e) { + // The client is dead, do nothing + } + } +} diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java index e8b95bc909..3470f27dff 100644 --- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java +++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java @@ -1,31 +1,729 @@ /* * Copyright (C) 2023 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 + * 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 + * 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. + * 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.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED; import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3; +import static android.net.thread.ThreadNetworkException.ERROR_ABORTED; +import static android.net.thread.ThreadNetworkException.ERROR_BUSY; +import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION; +import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR; +import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER; +import static android.net.thread.ThreadNetworkException.ERROR_RESOURCE_EXHAUSTED; +import static android.net.thread.ThreadNetworkException.ERROR_RESPONSE_BAD_FORMAT; +import static android.net.thread.ThreadNetworkException.ERROR_TIMEOUT; +import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL; +import static android.net.thread.ThreadNetworkException.ErrorCode; +import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_ABORT; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_BUSY; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_DETACHED; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_NO_BUFS; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_PARSE; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_REASSEMBLY_TIMEOUT; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_REJECTED; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_RESPONSE_TIMEOUT; +import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_UNSUPPORTED_CHANNEL; +import static com.android.server.thread.openthread.IOtDaemon.TUN_IF_NAME; + +import android.Manifest.permission; +import android.annotation.NonNull; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.LinkAddress; +import android.net.LinkProperties; +import android.net.NetworkAgent; +import android.net.NetworkAgentConfig; +import android.net.NetworkCapabilities; +import android.net.NetworkProvider; +import android.net.NetworkScore; +import android.net.thread.ActiveOperationalDataset; +import android.net.thread.IOperationReceiver; +import android.net.thread.IOperationalDatasetCallback; +import android.net.thread.IStateCallback; import android.net.thread.IThreadNetworkController; +import android.net.thread.PendingOperationalDataset; import android.net.thread.ThreadNetworkController; +import android.net.thread.ThreadNetworkController.DeviceRole; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.RemoteException; +import android.os.SystemClock; +import android.util.Log; -/** Implementation of the {@link ThreadNetworkController} API. */ -public final class ThreadNetworkControllerService extends IThreadNetworkController.Stub { +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.ServiceManagerWrapper; +import com.android.server.thread.openthread.IOtDaemon; +import com.android.server.thread.openthread.IOtDaemonCallback; +import com.android.server.thread.openthread.IOtStatusReceiver; +import com.android.server.thread.openthread.Ipv6AddressInfo; +import com.android.server.thread.openthread.OtDaemonState; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Implementation of the {@link ThreadNetworkController} API. + * + *

Threading model: This class is not Thread-safe and should only be accessed from the + * ThreadNetworkService class. Additional attention should be paid to handle the threading code + * correctly: 1. All member fields other than `mHandler` and `mContext` MUST be accessed from + * `mHandlerThread` 2. In the @Override methods, the actual work MUST be dispatched to the + * HandlerThread except for arguments or permissions checking + */ +final class ThreadNetworkControllerService extends IThreadNetworkController.Stub { + private static final String TAG = "ThreadNetworkService"; + + // Below member fields can be accessed from both the binder and handler threads + + private final Context mContext; + private final Handler mHandler; + + // Below member fields can only be accessed from the handler thread (`mHandlerThread`). In + // 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 HandlerThread mHandlerThread; + private final NetworkProvider mNetworkProvider; + private final Supplier mOtDaemonSupplier; + private final ConnectivityManager mConnectivityManager; + private final TunInterfaceController mTunIfController; + private final LinkProperties mLinkProperties = new LinkProperties(); + private final OtDaemonCallbackProxy mOtDaemonCallbackProxy = new OtDaemonCallbackProxy(); + + private IOtDaemon mOtDaemon; + private NetworkAgent mNetworkAgent; + + @VisibleForTesting + ThreadNetworkControllerService( + Context context, + HandlerThread handlerThread, + NetworkProvider networkProvider, + Supplier otDaemonSupplier, + ConnectivityManager connectivityManager, + TunInterfaceController tunIfController) { + mContext = context; + mHandlerThread = handlerThread; + mHandler = new Handler(handlerThread.getLooper()); + mNetworkProvider = networkProvider; + mOtDaemonSupplier = otDaemonSupplier; + mConnectivityManager = connectivityManager; + mTunIfController = tunIfController; + } + + public static ThreadNetworkControllerService newInstance(Context context) { + HandlerThread handlerThread = new HandlerThread("ThreadHandlerThread"); + handlerThread.start(); + NetworkProvider networkProvider = + new NetworkProvider(context, handlerThread.getLooper(), "ThreadNetworkProvider"); + + return new ThreadNetworkControllerService( + context, + handlerThread, + networkProvider, + () -> IOtDaemon.Stub.asInterface(ServiceManagerWrapper.waitForService("ot_daemon")), + context.getSystemService(ConnectivityManager.class), + new TunInterfaceController(TUN_IF_NAME)); + } + + private static NetworkCapabilities newNetworkCapabilities() { + return new NetworkCapabilities.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_THREAD) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED) + .build(); + } + + private static InetAddress addressInfoToInetAddress(Ipv6AddressInfo addressInfo) { + try { + return InetAddress.getByAddress(addressInfo.address); + } catch (UnknownHostException e) { + // This is impossible unless the Thread daemon is critically broken + return null; + } + } + + private static LinkAddress newLinkAddress(Ipv6AddressInfo addressInfo) { + long deprecationTimeMillis = + addressInfo.isPreferred + ? LinkAddress.LIFETIME_PERMANENT + : SystemClock.elapsedRealtime(); + + InetAddress address = addressInfoToInetAddress(addressInfo); + + // flags and scope will be adjusted automatically depending on the address and + // its lifetimes. + return new LinkAddress( + address, + addressInfo.prefixLength, + 0 /* flags */, + 0 /* scope */, + deprecationTimeMillis, + LinkAddress.LIFETIME_PERMANENT /* expirationTime */); + } + + private void initializeOtDaemon() { + try { + getOtDaemon(); + } catch (RemoteException e) { + Log.e(TAG, "Failed to initialize ot-daemon"); + } + } + + private IOtDaemon getOtDaemon() throws RemoteException { + if (mOtDaemon != null) { + return mOtDaemon; + } + + IOtDaemon otDaemon = mOtDaemonSupplier.get(); + if (otDaemon == null) { + throw new RemoteException("Internal error: failed to start OT daemon"); + } + otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0); + otDaemon.initialize(mTunIfController.getTunFd()); + otDaemon.registerStateCallback(mOtDaemonCallbackProxy, -1); + mOtDaemon = otDaemon; + return mOtDaemon; + } + + private void onOtDaemonDied() { + Log.w(TAG, "OT daemon became dead, clean up..."); + OperationReceiverWrapper.onOtDaemonDied(); + mOtDaemonCallbackProxy.onOtDaemonDied(); + mOtDaemon = null; + } + + public void initialize() { + mHandler.post( + () -> { + Log.d(TAG, "Initializing Thread system service..."); + try { + mTunIfController.createTunInterface(); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to create Thread tunnel interface", e); + } + mLinkProperties.setInterfaceName(TUN_IF_NAME); + mLinkProperties.setMtu(TunInterfaceController.MTU); + mConnectivityManager.registerNetworkProvider(mNetworkProvider); + + initializeOtDaemon(); + }); + } + + private void registerThreadNetwork() { + if (mNetworkAgent != null) { + return; + } + NetworkCapabilities netCaps = newNetworkCapabilities(); + NetworkScore score = + new NetworkScore.Builder() + .setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK) + .build(); + mNetworkAgent = + new NetworkAgent( + mContext, + mHandlerThread.getLooper(), + TAG, + netCaps, + mLinkProperties, + score, + new NetworkAgentConfig.Builder().build(), + mNetworkProvider) {}; + mNetworkAgent.register(); + mNetworkAgent.markConnected(); + Log.i(TAG, "Registered Thread network"); + } + + private void unregisterThreadNetwork() { + if (mNetworkAgent == null) { + // unregisterThreadNetwork can be called every time this device becomes detached or + // disabled and the mNetworkAgent may not be created in this cases + return; + } + + Log.d(TAG, "Unregistering Thread network agent"); + + mNetworkAgent.unregister(); + mNetworkAgent = null; + } + + private void updateTunInterfaceAddress(LinkAddress linkAddress, boolean isAdded) { + try { + if (isAdded) { + mTunIfController.addAddress(linkAddress); + } else { + mTunIfController.removeAddress(linkAddress); + } + } catch (IOException e) { + Log.e( + TAG, + String.format( + "Failed to %s Thread tun interface address %s", + (isAdded ? "add" : "remove"), linkAddress), + e); + } + } + + private void updateNetworkLinkProperties(LinkAddress linkAddress, boolean isAdded) { + if (isAdded) { + mLinkProperties.addLinkAddress(linkAddress); + } else { + mLinkProperties.removeLinkAddress(linkAddress); + } + + // The Thread daemon can send link property updates before the networkAgent is + // registered + if (mNetworkAgent != null) { + mNetworkAgent.sendLinkProperties(mLinkProperties); + } + } @Override public int getThreadVersion() { return THREAD_VERSION_1_3; } + + private void enforceAllCallingPermissionsGranted(String... permissions) { + for (String permission : permissions) { + mContext.enforceCallingPermission( + permission, "Permission " + permission + " is missing"); + } + } + + @Override + public void registerStateCallback(IStateCallback stateCallback) throws RemoteException { + enforceAllCallingPermissionsGranted(permission.ACCESS_NETWORK_STATE); + + mHandler.post(() -> mOtDaemonCallbackProxy.registerStateCallback(stateCallback)); + } + + @Override + public void unregisterStateCallback(IStateCallback stateCallback) throws RemoteException { + mHandler.post(() -> mOtDaemonCallbackProxy.unregisterStateCallback(stateCallback)); + } + + @Override + public void registerOperationalDatasetCallback(IOperationalDatasetCallback callback) + throws RemoteException { + enforceAllCallingPermissionsGranted( + permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED); + mHandler.post(() -> mOtDaemonCallbackProxy.registerDatasetCallback(callback)); + } + + @Override + public void unregisterOperationalDatasetCallback(IOperationalDatasetCallback callback) + throws RemoteException { + mHandler.post(() -> mOtDaemonCallbackProxy.unregisterDatasetCallback(callback)); + } + + private void checkOnHandlerThread() { + if (Looper.myLooper() != mHandlerThread.getLooper()) { + Log.wtf(TAG, "Must be on the handler thread!"); + } + } + + private IOtStatusReceiver newOtStatusReceiver(OperationReceiverWrapper receiver) { + return new IOtStatusReceiver.Stub() { + @Override + public void onSuccess() { + receiver.onSuccess(); + } + + @Override + public void onError(int otError, String message) { + receiver.onError(otErrorToAndroidError(otError), message); + } + }; + } + + @ErrorCode + private static int otErrorToAndroidError(int otError) { + // See external/openthread/include/openthread/error.h for OT error definition + switch (otError) { + case OT_ERROR_ABORT: + return ERROR_ABORTED; + case OT_ERROR_BUSY: + return ERROR_BUSY; + case OT_ERROR_DETACHED: + case OT_ERROR_INVALID_STATE: + return ERROR_FAILED_PRECONDITION; + case OT_ERROR_NO_BUFS: + return ERROR_RESOURCE_EXHAUSTED; + case OT_ERROR_PARSE: + return ERROR_RESPONSE_BAD_FORMAT; + case OT_ERROR_REASSEMBLY_TIMEOUT: + case OT_ERROR_RESPONSE_TIMEOUT: + return ERROR_TIMEOUT; + case OT_ERROR_REJECTED: + return ERROR_REJECTED_BY_PEER; + case OT_ERROR_UNSUPPORTED_CHANNEL: + return ERROR_UNSUPPORTED_CHANNEL; + default: + return ERROR_INTERNAL_ERROR; + } + } + + @Override + public void join( + @NonNull ActiveOperationalDataset activeDataset, @NonNull IOperationReceiver receiver) { + enforceAllCallingPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + OperationReceiverWrapper receiverWrapper = new OperationReceiverWrapper(receiver); + mHandler.post(() -> joinInternal(activeDataset, receiverWrapper)); + } + + private void joinInternal( + @NonNull ActiveOperationalDataset activeDataset, + @NonNull OperationReceiverWrapper receiver) { + checkOnHandlerThread(); + + try { + // The otDaemon.join() will leave first if this device is currently attached + getOtDaemon().join(activeDataset.toThreadTlvs(), newOtStatusReceiver(receiver)); + } catch (RemoteException e) { + Log.e(TAG, "otDaemon.join failed", e); + receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error"); + } + } + + @Override + public void scheduleMigration( + @NonNull PendingOperationalDataset pendingDataset, + @NonNull IOperationReceiver receiver) { + enforceAllCallingPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + OperationReceiverWrapper receiverWrapper = new OperationReceiverWrapper(receiver); + mHandler.post(() -> scheduleMigrationInternal(pendingDataset, receiverWrapper)); + } + + public void scheduleMigrationInternal( + @NonNull PendingOperationalDataset pendingDataset, + @NonNull OperationReceiverWrapper receiver) { + checkOnHandlerThread(); + + try { + getOtDaemon() + .scheduleMigration( + pendingDataset.toThreadTlvs(), newOtStatusReceiver(receiver)); + } catch (RemoteException e) { + Log.e(TAG, "otDaemon.scheduleMigration failed", e); + receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error"); + } + } + + @Override + public void leave(@NonNull IOperationReceiver receiver) throws RemoteException { + enforceAllCallingPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + mHandler.post(() -> leaveInternal(new OperationReceiverWrapper(receiver))); + } + + private void leaveInternal(@NonNull OperationReceiverWrapper receiver) { + checkOnHandlerThread(); + + try { + getOtDaemon().leave(newOtStatusReceiver(receiver)); + } catch (RemoteException e) { + // Oneway AIDL API should never throw? + receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error"); + } + } + + private void handleThreadInterfaceStateChanged(boolean isUp) { + try { + mTunIfController.setInterfaceUp(isUp); + Log.d(TAG, "Thread network interface becomes " + (isUp ? "up" : "down")); + } catch (IOException e) { + Log.e(TAG, "Failed to handle Thread interface state changes", e); + } + } + + private void handleDeviceRoleChanged(@DeviceRole int deviceRole) { + if (ThreadNetworkController.isAttached(deviceRole)) { + Log.d(TAG, "Attached to the Thread network"); + registerThreadNetwork(); + } else { + Log.d(TAG, "Detached from the Thread network"); + unregisterThreadNetwork(); + } + } + + private void handleAddressChanged(Ipv6AddressInfo addressInfo, boolean isAdded) { + checkOnHandlerThread(); + InetAddress address = addressInfoToInetAddress(addressInfo); + if (address.isMulticastAddress()) { + Log.i(TAG, "Ignoring multicast address " + address.getHostAddress()); + return; + } + + LinkAddress linkAddress = newLinkAddress(addressInfo); + Log.d(TAG, (isAdded ? "Adding" : "Removing") + " address " + linkAddress); + + updateTunInterfaceAddress(linkAddress, isAdded); + updateNetworkLinkProperties(linkAddress, isAdded); + } + + private static final class CallbackMetadata { + private static long gId = 0; + + // The unique ID + final long id; + + final IBinder.DeathRecipient deathRecipient; + + CallbackMetadata(IBinder.DeathRecipient deathRecipient) { + this.id = allocId(); + this.deathRecipient = deathRecipient; + } + + private static long allocId() { + if (gId == Long.MAX_VALUE) { + gId = 0; + } + return gId++; + } + } + + /** + * Handles and forwards Thread daemon callbacks. This class must be accessed from the {@code + * mHandlerThread}. + */ + private final class OtDaemonCallbackProxy extends IOtDaemonCallback.Stub { + private final Map mStateCallbacks = new HashMap<>(); + private final Map mOpDatasetCallbacks = + new HashMap<>(); + + private OtDaemonState mState; + private ActiveOperationalDataset mActiveDataset; + private PendingOperationalDataset mPendingDataset; + + public void registerStateCallback(IStateCallback callback) { + checkOnHandlerThread(); + if (mStateCallbacks.containsKey(callback)) { + return; + } + + IBinder.DeathRecipient deathRecipient = + () -> mHandler.post(() -> unregisterStateCallback(callback)); + CallbackMetadata callbackMetadata = new CallbackMetadata(deathRecipient); + mStateCallbacks.put(callback, callbackMetadata); + try { + callback.asBinder().linkToDeath(deathRecipient, 0); + } catch (RemoteException e) { + mStateCallbacks.remove(callback); + // This is thrown when the client is dead, do nothing + } + + try { + getOtDaemon().registerStateCallback(this, callbackMetadata.id); + } catch (RemoteException e) { + // oneway operation should never fail + } + } + + public void unregisterStateCallback(IStateCallback callback) { + checkOnHandlerThread(); + if (!mStateCallbacks.containsKey(callback)) { + return; + } + callback.asBinder().unlinkToDeath(mStateCallbacks.remove(callback).deathRecipient, 0); + } + + public void registerDatasetCallback(IOperationalDatasetCallback callback) { + checkOnHandlerThread(); + if (mOpDatasetCallbacks.containsKey(callback)) { + return; + } + + IBinder.DeathRecipient deathRecipient = + () -> mHandler.post(() -> unregisterDatasetCallback(callback)); + CallbackMetadata callbackMetadata = new CallbackMetadata(deathRecipient); + mOpDatasetCallbacks.put(callback, callbackMetadata); + try { + callback.asBinder().linkToDeath(deathRecipient, 0); + } catch (RemoteException e) { + mOpDatasetCallbacks.remove(callback); + } + + try { + getOtDaemon().registerStateCallback(this, callbackMetadata.id); + } catch (RemoteException e) { + // oneway operation should never fail + } + } + + public void unregisterDatasetCallback(IOperationalDatasetCallback callback) { + checkOnHandlerThread(); + if (!mOpDatasetCallbacks.containsKey(callback)) { + return; + } + callback.asBinder() + .unlinkToDeath(mOpDatasetCallbacks.remove(callback).deathRecipient, 0); + } + + public void onOtDaemonDied() { + checkOnHandlerThread(); + if (mState == null) { + return; + } + + // If this device is already STOPPED or DETACHED, do nothing + if (!ThreadNetworkController.isAttached(mState.deviceRole)) { + return; + } + + // The Thread device role is considered DETACHED when the OT daemon process is dead + handleDeviceRoleChanged(DEVICE_ROLE_DETACHED); + for (IStateCallback callback : mStateCallbacks.keySet()) { + try { + callback.onDeviceRoleChanged(DEVICE_ROLE_DETACHED); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + } + } + + @Override + public void onStateChanged(OtDaemonState newState, long listenerId) { + mHandler.post(() -> onStateChangedInternal(newState, listenerId)); + } + + private void onStateChangedInternal(OtDaemonState newState, long listenerId) { + checkOnHandlerThread(); + onInterfaceStateChanged(newState.isInterfaceUp); + onDeviceRoleChanged(newState.deviceRole, listenerId); + onPartitionIdChanged(newState.partitionId, listenerId); + mState = newState; + + ActiveOperationalDataset newActiveDataset; + try { + if (newState.activeDatasetTlvs.length != 0) { + newActiveDataset = + ActiveOperationalDataset.fromThreadTlvs(newState.activeDatasetTlvs); + } else { + newActiveDataset = null; + } + onActiveOperationalDatasetChanged(newActiveDataset, listenerId); + mActiveDataset = newActiveDataset; + } catch (IllegalArgumentException e) { + // Is unlikely that OT will generate invalid Operational Dataset + Log.w(TAG, "Ignoring invalid Active Operational Dataset changes", e); + } + + PendingOperationalDataset newPendingDataset; + try { + if (newState.pendingDatasetTlvs.length != 0) { + newPendingDataset = + PendingOperationalDataset.fromThreadTlvs(newState.pendingDatasetTlvs); + } else { + newPendingDataset = null; + } + onPendingOperationalDatasetChanged(newPendingDataset, listenerId); + mPendingDataset = newPendingDataset; + } catch (IllegalArgumentException e) { + Log.w(TAG, "Ignoring invalid Pending Operational Dataset changes", e); + } + } + + private void onInterfaceStateChanged(boolean isUp) { + checkOnHandlerThread(); + if (mState == null || mState.isInterfaceUp != isUp) { + handleThreadInterfaceStateChanged(isUp); + } + } + + private void onDeviceRoleChanged(@DeviceRole int deviceRole, long listenerId) { + checkOnHandlerThread(); + boolean hasChange = (mState == null || mState.deviceRole != deviceRole); + if (hasChange) { + handleDeviceRoleChanged(deviceRole); + } + + for (var callbackEntry : mStateCallbacks.entrySet()) { + if (!hasChange && callbackEntry.getValue().id != listenerId) { + continue; + } + try { + callbackEntry.getKey().onDeviceRoleChanged(deviceRole); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + } + } + + private void onPartitionIdChanged(long partitionId, long listenerId) { + checkOnHandlerThread(); + boolean hasChange = (mState == null || mState.partitionId != partitionId); + + for (var callbackEntry : mStateCallbacks.entrySet()) { + if (!hasChange && callbackEntry.getValue().id != listenerId) { + continue; + } + try { + callbackEntry.getKey().onPartitionIdChanged(partitionId); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + } + } + + private void onActiveOperationalDatasetChanged( + ActiveOperationalDataset activeDataset, long listenerId) { + checkOnHandlerThread(); + boolean hasChange = !Objects.equals(mActiveDataset, activeDataset); + + for (var callbackEntry : mOpDatasetCallbacks.entrySet()) { + if (!hasChange && callbackEntry.getValue().id != listenerId) { + continue; + } + try { + callbackEntry.getKey().onActiveOperationalDatasetChanged(activeDataset); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + } + } + + private void onPendingOperationalDatasetChanged( + PendingOperationalDataset pendingDataset, long listenerId) { + checkOnHandlerThread(); + boolean hasChange = !Objects.equals(mPendingDataset, pendingDataset); + for (var callbackEntry : mOpDatasetCallbacks.entrySet()) { + if (!hasChange && callbackEntry.getValue().id != listenerId) { + continue; + } + try { + callbackEntry.getKey().onPendingOperationalDatasetChanged(pendingDataset); + } catch (RemoteException ignored) { + // do nothing if the client is dead + } + } + } + + @Override + public void onAddressChanged(Ipv6AddressInfo addressInfo, boolean isAdded) { + mHandler.post(() -> handleAddressChanged(addressInfo, isAdded)); + } + } } diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkService.java b/thread/service/java/com/android/server/thread/ThreadNetworkService.java index c6d47df60b..cc694a1440 100644 --- a/thread/service/java/com/android/server/thread/ThreadNetworkService.java +++ b/thread/service/java/com/android/server/thread/ThreadNetworkService.java @@ -16,6 +16,7 @@ package com.android.server.thread; +import android.annotation.Nullable; import android.content.Context; import android.net.thread.IThreadNetworkController; import android.net.thread.IThreadNetworkManager; @@ -29,16 +30,12 @@ import java.util.List; * Implementation of the Thread network service. This is the entry point of Android Thread feature. */ public class ThreadNetworkService extends IThreadNetworkManager.Stub { - private final ThreadNetworkControllerService mControllerService; + private final Context mContext; + @Nullable private ThreadNetworkControllerService mControllerService; /** Creates a new {@link ThreadNetworkService} object. */ public ThreadNetworkService(Context context) { - this(context, new ThreadNetworkControllerService()); - } - - private ThreadNetworkService( - Context context, ThreadNetworkControllerService controllerService) { - mControllerService = controllerService; + mContext = context; } /** @@ -48,12 +45,16 @@ public class ThreadNetworkService extends IThreadNetworkManager.Stub { */ public void onBootPhase(int phase) { if (phase == SystemService.PHASE_BOOT_COMPLETED) { - // TODO: initialize ThreadNetworkManagerService + mControllerService = ThreadNetworkControllerService.newInstance(mContext); + mControllerService.initialize(); } } @Override public List getAllThreadNetworkControllers() { + if (mControllerService == null) { + return Collections.emptyList(); + } return Collections.singletonList(mControllerService); } } diff --git a/thread/service/java/com/android/server/thread/TunInterfaceController.java b/thread/service/java/com/android/server/thread/TunInterfaceController.java new file mode 100644 index 0000000000..ac65b111a2 --- /dev/null +++ b/thread/service/java/com/android/server/thread/TunInterfaceController.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2023 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 android.net.LinkAddress; +import android.net.util.SocketUtils; +import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; +import android.util.Log; + +import com.android.net.module.util.netlink.NetlinkUtils; +import com.android.net.module.util.netlink.RtNetlinkAddressMessage; + +import java.io.FileDescriptor; +import java.io.IOException; + +/** Controller for virtual/tunnel network interfaces. */ +public class TunInterfaceController { + private static final String TAG = "TunIfController"; + static final int MTU = 1280; + + static { + System.loadLibrary("service-thread-jni"); + } + + private final String mIfName; + private ParcelFileDescriptor mParcelTunFd; + private FileDescriptor mNetlinkSocket; + private static int sNetlinkSeqNo = 0; + + /** Creates a new {@link TunInterfaceController} instance for given interface. */ + public TunInterfaceController(String interfaceName) { + this.mIfName = interfaceName; + } + + /** + * Creates the tunnel interface. + * + * @throws IOException if failed to create the interface + */ + public void createTunInterface() throws IOException { + mParcelTunFd = ParcelFileDescriptor.adoptFd(nativeCreateTunInterface(mIfName, MTU)); + try { + mNetlinkSocket = NetlinkUtils.netlinkSocketForProto(OsConstants.NETLINK_ROUTE); + } catch (ErrnoException e) { + throw new IOException("Failed to create netlink socket", e); + } + } + + public void destroyTunInterface() { + try { + mParcelTunFd.close(); + SocketUtils.closeSocket(mNetlinkSocket); + } catch (IOException e) { + // Should never fail + } + mParcelTunFd = null; + mNetlinkSocket = null; + } + + /** Returns the FD of the tunnel interface. */ + public ParcelFileDescriptor getTunFd() { + return mParcelTunFd; + } + + private native int nativeCreateTunInterface(String interfaceName, int mtu) throws IOException; + + /** Sets the interface up or down according to {@code isUp}. */ + public void setInterfaceUp(boolean isUp) throws IOException { + nativeSetInterfaceUp(mIfName, isUp); + } + + private native void nativeSetInterfaceUp(String interfaceName, boolean isUp) throws IOException; + + /** Adds a new address to the interface. */ + public void addAddress(LinkAddress address) throws IOException { + Log.d(TAG, "Adding address " + address + " with flags: " + address.getFlags()); + + long validLifetimeSeconds; + long preferredLifetimeSeconds; + + if (address.getDeprecationTime() == LinkAddress.LIFETIME_PERMANENT + || address.getDeprecationTime() == LinkAddress.LIFETIME_UNKNOWN) { + validLifetimeSeconds = 0xffffffffL; + } else { + validLifetimeSeconds = + Math.max( + (address.getDeprecationTime() - SystemClock.elapsedRealtime()) / 1000L, + 0L); + } + + if (address.getExpirationTime() == LinkAddress.LIFETIME_PERMANENT + || address.getExpirationTime() == LinkAddress.LIFETIME_UNKNOWN) { + preferredLifetimeSeconds = 0xffffffffL; + } else { + preferredLifetimeSeconds = + Math.max( + (address.getExpirationTime() - SystemClock.elapsedRealtime()) / 1000L, + 0L); + } + + byte[] message = + RtNetlinkAddressMessage.newRtmNewAddressMessage( + sNetlinkSeqNo, + address.getAddress(), + (short) address.getPrefixLength(), + address.getFlags(), + (byte) address.getScope(), + Os.if_nametoindex(mIfName), + validLifetimeSeconds, + preferredLifetimeSeconds); + try { + Os.write(mNetlinkSocket, message, 0, message.length); + } catch (ErrnoException e) { + throw new IOException("Failed to send netlink message", e); + } + } + + /** Removes an address from the interface. */ + public void removeAddress(LinkAddress address) throws IOException { + // TODO(b/263222068): remove address with netlink + } +} diff --git a/thread/service/jni/com_android_server_thread_TunInterfaceController.cpp b/thread/service/jni/com_android_server_thread_TunInterfaceController.cpp new file mode 100644 index 0000000000..ed39fab9b0 --- /dev/null +++ b/thread/service/jni/com_android_server_thread_TunInterfaceController.cpp @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2023 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. + */ + +#define LOG_TAG "jniThreadTun" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "jni.h" +#include "nativehelper/JNIHelp.h" +#include "nativehelper/scoped_utf_chars.h" + +namespace android { +static jint com_android_server_thread_TunInterfaceController_createTunInterface( + JNIEnv* env, jobject clazz, jstring interfaceName, jint mtu) { + ScopedUtfChars ifName(env, interfaceName); + + int fd = open("/dev/net/tun", O_RDWR | O_NONBLOCK | O_CLOEXEC); + if (fd == -1) { + jniThrowExceptionFmt(env, "java/io/IOException", "open tun device failed (%s)", + strerror(errno)); + return -1; + } + + struct ifreq ifr = { + .ifr_flags = IFF_TUN | IFF_NO_PI | static_cast(IFF_TUN_EXCL), + }; + strlcpy(ifr.ifr_name, ifName.c_str(), sizeof(ifr.ifr_name)); + + if (ioctl(fd, TUNSETIFF, &ifr, sizeof(ifr)) != 0) { + close(fd); + jniThrowExceptionFmt(env, "java/io/IOException", "ioctl(TUNSETIFF) failed (%s)", + strerror(errno)); + return -1; + } + + int inet6 = socket(AF_INET6, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, IPPROTO_IP); + if (inet6 == -1) { + close(fd); + jniThrowExceptionFmt(env, "java/io/IOException", "create inet6 socket failed (%s)", + strerror(errno)); + return -1; + } + ifr.ifr_mtu = mtu; + if (ioctl(inet6, SIOCSIFMTU, &ifr) != 0) { + close(fd); + close(inet6); + jniThrowExceptionFmt(env, "java/io/IOException", "ioctl(SIOCSIFMTU) failed (%s)", + strerror(errno)); + return -1; + } + + close(inet6); + return fd; +} + +static void com_android_server_thread_TunInterfaceController_setInterfaceUp( + JNIEnv* env, jobject clazz, jstring interfaceName, jboolean isUp) { + struct ifreq ifr; + ScopedUtfChars ifName(env, interfaceName); + + ifr.ifr_flags = isUp ? IFF_UP : 0; + strlcpy(ifr.ifr_name, ifName.c_str(), sizeof(ifr.ifr_name)); + + int inet6 = socket(AF_INET6, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, IPPROTO_IP); + if (inet6 == -1) { + jniThrowExceptionFmt(env, "java/io/IOException", "create inet6 socket failed (%s)", + strerror(errno)); + } + + if (ioctl(inet6, SIOCSIFFLAGS, &ifr) != 0) { + close(inet6); + jniThrowExceptionFmt(env, "java/io/IOException", "ioctl(SIOCSIFFLAGS) failed (%s)", + strerror(errno)); + } + + close(inet6); +} + +/* + * JNI registration. + */ + +static const JNINativeMethod gMethods[] = { + /* name, signature, funcPtr */ + {"nativeCreateTunInterface", + "(Ljava/lang/String;I)I", + (void*)com_android_server_thread_TunInterfaceController_createTunInterface}, + {"nativeSetInterfaceUp", + "(Ljava/lang/String;Z)V", + (void*)com_android_server_thread_TunInterfaceController_setInterfaceUp}, +}; + +int register_com_android_server_thread_TunInterfaceController(JNIEnv* env) { + return jniRegisterNativeMethods(env, "com/android/server/thread/TunInterfaceController", + gMethods, NELEM(gMethods)); +} + +}; // namespace android diff --git a/thread/service/jni/onload.cpp b/thread/service/jni/onload.cpp new file mode 100644 index 0000000000..508166483c --- /dev/null +++ b/thread/service/jni/onload.cpp @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2023 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. + */ + +#include "jni.h" +#include "utils/Log.h" + +namespace android { +int register_com_android_server_thread_TunInterfaceController(JNIEnv* env); +} + +using namespace android; + +extern "C" jint JNI_OnLoad(JavaVM* vm, void* /* reserved */) { + JNIEnv* env = NULL; + + if (vm->GetEnv((void**) &env, JNI_VERSION_1_4) != JNI_OK) { + ALOGE("GetEnv failed!"); + return -1; + } + ALOG_ASSERT(env != NULL, "Could not retrieve the env!"); + + register_com_android_server_thread_TunInterfaceController(env); + return JNI_VERSION_1_4; +} 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 b3118f42ec..cfe310c05b 100644 --- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java +++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java @@ -16,58 +16,576 @@ package android.net.thread.cts; +import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED; import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3; +import static android.net.thread.ThreadNetworkException.ERROR_ABORTED; +import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION; +import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; 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 java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.Manifest.permission; import android.content.Context; +import android.net.thread.ActiveOperationalDataset; +import android.net.thread.OperationalDatasetTimestamp; +import android.net.thread.PendingOperationalDataset; import android.net.thread.ThreadNetworkController; +import android.net.thread.ThreadNetworkController.OperationalDatasetCallback; +import android.net.thread.ThreadNetworkController.StateCallback; +import android.net.thread.ThreadNetworkException; import android.net.thread.ThreadNetworkManager; import android.os.Build; +import android.os.OutcomeReceiver; import androidx.test.core.app.ApplicationProvider; -import androidx.test.filters.SmallTest; +import androidx.test.filters.LargeTest; import com.android.testutils.DevSdkIgnoreRule; import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo; import com.android.testutils.DevSdkIgnoreRunner; +import com.google.common.util.concurrent.SettableFuture; + +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** CTS tests for {@link ThreadNetworkController}. */ -@SmallTest +@LargeTest @RunWith(DevSdkIgnoreRunner.class) @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) // Thread is available on only U+ public class ThreadNetworkControllerTest { + private static final int CALLBACK_TIMEOUT_MILLIS = 1000; + private static final String PERMISSION_THREAD_NETWORK_PRIVILEGED = + "android.permission.THREAD_NETWORK_PRIVILEGED"; + @Rule public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule(); private final Context mContext = ApplicationProvider.getApplicationContext(); + private ExecutorService mExecutor; private ThreadNetworkManager mManager; + private Set mGrantedPermissions; + @Before public void setUp() { + mExecutor = Executors.newSingleThreadExecutor(); mManager = mContext.getSystemService(ThreadNetworkManager.class); + mGrantedPermissions = new HashSet(); // TODO: we will also need it in tearDown(), it's better to have a Rule to skip // tests if a feature is not available. assumeNotNull(mManager); } + @After + public void tearDown() throws Exception { + if (mManager != null) { + leaveAndWait(); + dropPermissions(); + } + } + private List getAllControllers() { return mManager.getAllThreadNetworkControllers(); } + private void leaveAndWait() throws Exception { + grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + SettableFuture future = SettableFuture.create(); + controller.leave(mExecutor, future::set); + future.get(); + } + } + + 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 dropPermissions() { + getInstrumentation().getUiAutomation().dropShellPermissionIdentity(); + } + + private static boolean isAttached(ThreadNetworkController controller) throws Exception { + return ThreadNetworkController.isAttached(getDeviceRole(controller)); + } + + private static int getDeviceRole(ThreadNetworkController controller) throws Exception { + SettableFuture future = SettableFuture.create(); + StateCallback callback = future::set; + controller.registerStateCallback(directExecutor(), callback); + int role = future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS); + controller.unregisterStateCallback(callback); + return role; + } + + private static int waitForStateAnyOf( + ThreadNetworkController controller, List deviceRoles) throws Exception { + SettableFuture future = SettableFuture.create(); + StateCallback callback = + newRole -> { + if (deviceRoles.contains(newRole)) { + future.set(newRole); + } + }; + controller.registerStateCallback(directExecutor(), callback); + int role = future.get(); + controller.unregisterStateCallback(callback); + return role; + } + + private static ActiveOperationalDataset getActiveOperationalDataset( + ThreadNetworkController controller) throws Exception { + SettableFuture future = SettableFuture.create(); + OperationalDatasetCallback callback = future::set; + controller.registerOperationalDatasetCallback(directExecutor(), callback); + ActiveOperationalDataset dataset = future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS); + controller.unregisterOperationalDatasetCallback(callback); + return dataset; + } + + private static PendingOperationalDataset getPendingOperationalDataset( + ThreadNetworkController controller) throws Exception { + SettableFuture activeFuture = SettableFuture.create(); + SettableFuture pendingFuture = SettableFuture.create(); + controller.registerOperationalDatasetCallback( + directExecutor(), newDatasetCallback(activeFuture, pendingFuture)); + return pendingFuture.get(); + } + + private static OperationalDatasetCallback newDatasetCallback( + SettableFuture activeFuture, + SettableFuture pendingFuture) { + return new OperationalDatasetCallback() { + @Override + public void onActiveOperationalDatasetChanged( + ActiveOperationalDataset activeOpDataset) { + activeFuture.set(activeOpDataset); + } + + @Override + public void onPendingOperationalDatasetChanged( + PendingOperationalDataset pendingOpDataset) { + pendingFuture.set(pendingOpDataset); + } + }; + } + @Test public void getThreadVersion_returnsAtLeastThreadVersion1P3() { for (ThreadNetworkController controller : getAllControllers()) { assertThat(controller.getThreadVersion()).isAtLeast(THREAD_VERSION_1_3); } } + + @Test + public void registerStateCallback_permissionsGranted_returnsCurrentStates() throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE); + + for (ThreadNetworkController controller : getAllControllers()) { + SettableFuture deviceRole = SettableFuture.create(); + + controller.registerStateCallback(mExecutor, role -> deviceRole.set(role)); + + assertThat(deviceRole.get()).isEqualTo(DEVICE_ROLE_STOPPED); + } + } + + @Test + public void registerStateCallback_noPermissions_throwsSecurityException() throws Exception { + dropPermissions(); + + for (ThreadNetworkController controller : getAllControllers()) { + assertThrows( + SecurityException.class, + () -> controller.registerStateCallback(mExecutor, role -> {})); + } + } + + @Test + public void registerStateCallback_alreadyRegistered_throwsIllegalArgumentException() + throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE); + + for (ThreadNetworkController controller : getAllControllers()) { + SettableFuture deviceRole = SettableFuture.create(); + StateCallback callback = role -> deviceRole.set(role); + controller.registerStateCallback(mExecutor, callback); + + assertThrows( + IllegalArgumentException.class, + () -> controller.registerStateCallback(mExecutor, callback)); + } + } + + @Test + public void unregisterStateCallback_callbackRegistered_success() throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE); + for (ThreadNetworkController controller : getAllControllers()) { + SettableFuture deviceRole = SettableFuture.create(); + StateCallback callback = role -> deviceRole.set(role); + controller.registerStateCallback(mExecutor, callback); + + controller.unregisterStateCallback(callback); + } + } + + @Test + public void unregisterStateCallback_callbackNotRegistered_throwsIllegalArgumentException() + throws Exception { + for (ThreadNetworkController controller : getAllControllers()) { + SettableFuture deviceRole = SettableFuture.create(); + StateCallback callback = role -> deviceRole.set(role); + + assertThrows( + IllegalArgumentException.class, + () -> controller.unregisterStateCallback(callback)); + } + } + + @Test + public void unregisterStateCallback_alreadyUnregistered_throwsIllegalArgumentException() + throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE); + for (ThreadNetworkController controller : getAllControllers()) { + SettableFuture deviceRole = SettableFuture.create(); + StateCallback callback = role -> deviceRole.set(role); + controller.registerStateCallback(mExecutor, callback); + controller.unregisterStateCallback(callback); + + assertThrows( + IllegalArgumentException.class, + () -> controller.unregisterStateCallback(callback)); + } + } + + @Test + public void registerOperationalDatasetCallback_permissionsGranted_returnsCurrentStates() + throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + SettableFuture activeFuture = SettableFuture.create(); + SettableFuture pendingFuture = SettableFuture.create(); + + controller.registerOperationalDatasetCallback( + mExecutor, newDatasetCallback(activeFuture, pendingFuture)); + + assertThat(activeFuture.get()).isNull(); + assertThat(pendingFuture.get()).isNull(); + } + } + + private static OutcomeReceiver newOutcomeReceiver( + SettableFuture future) { + return new OutcomeReceiver() { + @Override + public void onResult(V result) { + future.set(result); + } + + @Override + public void onError(ThreadNetworkException e) { + future.setException(e); + } + }; + } + + @Test + public void join_withPrivilegedPermission_success() throws Exception { + grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + ActiveOperationalDataset activeDataset = ActiveOperationalDataset.createRandomDataset(); + SettableFuture joinFuture = SettableFuture.create(); + + controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture)); + joinFuture.get(); + + grantPermissions(permission.ACCESS_NETWORK_STATE); + assertThat(isAttached(controller)).isTrue(); + assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset); + } + } + + @Test + public void join_withoutPrivilegedPermission_throwsSecurityException() { + dropPermissions(); + + for (ThreadNetworkController controller : getAllControllers()) { + ActiveOperationalDataset activeDataset = ActiveOperationalDataset.createRandomDataset(); + + assertThrows( + SecurityException.class, + () -> controller.join(activeDataset, mExecutor, v -> {})); + } + } + + @Test + public void join_concurrentRequests_firstOneIsAborted() throws Exception { + grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + final byte[] KEY_1 = new byte[] {1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; + final byte[] KEY_2 = new byte[] {2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}; + for (ThreadNetworkController controller : getAllControllers()) { + ActiveOperationalDataset activeDataset1 = + new ActiveOperationalDataset.Builder( + ActiveOperationalDataset.createRandomDataset()) + .setNetworkKey(KEY_1) + .build(); + ActiveOperationalDataset activeDataset2 = + new ActiveOperationalDataset.Builder(activeDataset1) + .setNetworkKey(KEY_2) + .build(); + SettableFuture joinFuture1 = SettableFuture.create(); + SettableFuture joinFuture2 = SettableFuture.create(); + + controller.join(activeDataset1, mExecutor, newOutcomeReceiver(joinFuture1)); + controller.join(activeDataset2, mExecutor, newOutcomeReceiver(joinFuture2)); + + ThreadNetworkException thrown = + (ThreadNetworkException) + assertThrows(ExecutionException.class, joinFuture1::get).getCause(); + assertThat(thrown.getErrorCode()).isEqualTo(ERROR_ABORTED); + joinFuture2.get(); + grantPermissions(permission.ACCESS_NETWORK_STATE); + assertThat(isAttached(controller)).isTrue(); + assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset2); + } + } + + @Test + public void leave_withPrivilegedPermission_success() throws Exception { + grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + ActiveOperationalDataset activeDataset = ActiveOperationalDataset.createRandomDataset(); + SettableFuture joinFuture = SettableFuture.create(); + SettableFuture leaveFuture = SettableFuture.create(); + controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture)); + joinFuture.get(); + + controller.leave(mExecutor, newOutcomeReceiver(leaveFuture)); + leaveFuture.get(); + + grantPermissions(permission.ACCESS_NETWORK_STATE); + assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED); + } + } + + @Test + public void leave_withoutPrivilegedPermission_throwsSecurityException() { + dropPermissions(); + + for (ThreadNetworkController controller : getAllControllers()) { + assertThrows(SecurityException.class, () -> controller.leave(mExecutor, v -> {})); + } + } + + @Test + public void leave_concurrentRequests_bothSuccess() throws Exception { + grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + ActiveOperationalDataset activeDataset = ActiveOperationalDataset.createRandomDataset(); + SettableFuture joinFuture = SettableFuture.create(); + SettableFuture leaveFuture1 = SettableFuture.create(); + SettableFuture leaveFuture2 = SettableFuture.create(); + controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture)); + joinFuture.get(); + + controller.leave(mExecutor, newOutcomeReceiver(leaveFuture1)); + controller.leave(mExecutor, newOutcomeReceiver(leaveFuture2)); + + leaveFuture1.get(); + leaveFuture2.get(); + grantPermissions(permission.ACCESS_NETWORK_STATE); + assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED); + } + } + + @Test + public void scheduleMigration_withPrivilegedPermission_success() throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + ActiveOperationalDataset activeDataset1 = + new ActiveOperationalDataset.Builder( + ActiveOperationalDataset.createRandomDataset()) + .setActiveTimestamp(new OperationalDatasetTimestamp(1L, 0, false)) + .setExtendedPanId(new byte[] {1, 1, 1, 1, 1, 1, 1, 1}) + .build(); + ActiveOperationalDataset activeDataset2 = + new ActiveOperationalDataset.Builder(activeDataset1) + .setActiveTimestamp(new OperationalDatasetTimestamp(2L, 0, false)) + .setNetworkName("ThreadNet2") + .build(); + PendingOperationalDataset pendingDataset2 = + new PendingOperationalDataset( + activeDataset2, + OperationalDatasetTimestamp.fromInstant(Instant.now()), + Duration.ofSeconds(30)); + SettableFuture joinFuture = SettableFuture.create(); + SettableFuture migrateFuture = SettableFuture.create(); + controller.join(activeDataset1, mExecutor, newOutcomeReceiver(joinFuture)); + joinFuture.get(); + + controller.scheduleMigration( + pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture)); + + migrateFuture.get(); + Thread.sleep(35 * 1000); + assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset2); + assertThat(getPendingOperationalDataset(controller)).isNull(); + } + } + + @Test + public void scheduleMigration_whenNotAttached_failWithPreconditionError() throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + PendingOperationalDataset pendingDataset = + new PendingOperationalDataset( + ActiveOperationalDataset.createRandomDataset(), + OperationalDatasetTimestamp.fromInstant(Instant.now()), + Duration.ofSeconds(30)); + SettableFuture migrateFuture = SettableFuture.create(); + + controller.scheduleMigration( + pendingDataset, mExecutor, newOutcomeReceiver(migrateFuture)); + + ThreadNetworkException thrown = + (ThreadNetworkException) + assertThrows(ExecutionException.class, migrateFuture::get).getCause(); + assertThat(thrown.getErrorCode()).isEqualTo(ERROR_FAILED_PRECONDITION); + } + } + + @Test + public void scheduleMigration_secondRequestHasSmallerTimestamp_rejectedByLeader() + throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + final ActiveOperationalDataset activeDataset = + new ActiveOperationalDataset.Builder( + ActiveOperationalDataset.createRandomDataset()) + .setActiveTimestamp(new OperationalDatasetTimestamp(1L, 0, false)) + .setNetworkName("testNet") + .build(); + ActiveOperationalDataset activeDataset1 = + new ActiveOperationalDataset.Builder(activeDataset) + .setActiveTimestamp(new OperationalDatasetTimestamp(2L, 0, false)) + .setNetworkName("testNet1") + .build(); + PendingOperationalDataset pendingDataset1 = + new PendingOperationalDataset( + activeDataset1, + new OperationalDatasetTimestamp(100, 0, false), + Duration.ofSeconds(30)); + ActiveOperationalDataset activeDataset2 = + new ActiveOperationalDataset.Builder(activeDataset) + .setActiveTimestamp(new OperationalDatasetTimestamp(3L, 0, false)) + .setNetworkName("testNet2") + .build(); + PendingOperationalDataset pendingDataset2 = + new PendingOperationalDataset( + activeDataset2, + new OperationalDatasetTimestamp(20, 0, false), + Duration.ofSeconds(30)); + SettableFuture joinFuture = SettableFuture.create(); + SettableFuture migrateFuture1 = SettableFuture.create(); + SettableFuture migrateFuture2 = SettableFuture.create(); + controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture)); + joinFuture.get(); + + controller.scheduleMigration( + pendingDataset1, mExecutor, newOutcomeReceiver(migrateFuture1)); + migrateFuture1.get(); + controller.scheduleMigration( + pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture2)); + + ThreadNetworkException thrown = + (ThreadNetworkException) + assertThrows(ExecutionException.class, migrateFuture2::get).getCause(); + assertThat(thrown.getErrorCode()).isEqualTo(ERROR_REJECTED_BY_PEER); + } + } + + @Test + public void scheduleMigration_secondRequestHasLargerTimestamp_success() throws Exception { + grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED); + + for (ThreadNetworkController controller : getAllControllers()) { + final ActiveOperationalDataset activeDataset = + new ActiveOperationalDataset.Builder( + ActiveOperationalDataset.createRandomDataset()) + .setActiveTimestamp(new OperationalDatasetTimestamp(1L, 0, false)) + .setNetworkName("testNet") + .build(); + ActiveOperationalDataset activeDataset1 = + new ActiveOperationalDataset.Builder(activeDataset) + .setActiveTimestamp(new OperationalDatasetTimestamp(2L, 0, false)) + .setNetworkName("testNet1") + .build(); + PendingOperationalDataset pendingDataset1 = + new PendingOperationalDataset( + activeDataset1, + new OperationalDatasetTimestamp(100, 0, false), + Duration.ofSeconds(30)); + ActiveOperationalDataset activeDataset2 = + new ActiveOperationalDataset.Builder(activeDataset) + .setActiveTimestamp(new OperationalDatasetTimestamp(3L, 0, false)) + .setNetworkName("testNet2") + .build(); + PendingOperationalDataset pendingDataset2 = + new PendingOperationalDataset( + activeDataset2, + new OperationalDatasetTimestamp(200, 0, false), + Duration.ofSeconds(30)); + SettableFuture joinFuture = SettableFuture.create(); + SettableFuture migrateFuture1 = SettableFuture.create(); + SettableFuture migrateFuture2 = SettableFuture.create(); + controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture)); + joinFuture.get(); + + controller.scheduleMigration( + pendingDataset1, mExecutor, newOutcomeReceiver(migrateFuture1)); + migrateFuture1.get(); + controller.scheduleMigration( + pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture2)); + + migrateFuture2.get(); + Thread.sleep(35 * 1000); + assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset2); + assertThat(getPendingOperationalDataset(controller)).isNull(); + } + } }