[Thread][API] add API for ephemeral key.
This CL adds the API for starting/stopping the ephemeral key mode, and
subscribing to the ephemeral key state changes.
Ephemeral key mode is started with a limit amount of time to allow
Thread credential sharing.
Bug: 348323500
Test: atest CtsThreadNetworkTestCases
Change-Id: I540c88ec4668291358fbe87a678d8f9d31a67e54
diff --git a/thread/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl
index 9d0a571..57c365b 100644
--- a/thread/framework/java/android/net/thread/IStateCallback.aidl
+++ b/thread/framework/java/android/net/thread/IStateCallback.aidl
@@ -23,4 +23,6 @@
void onDeviceRoleChanged(int deviceRole);
void onPartitionIdChanged(long partitionId);
void onThreadEnableStateChanged(int enabledState);
+ void onEphemeralKeyStateChanged(
+ int ephemeralKeyState, @nullable String ephemeralKey, long expiryMillis);
}
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
index b7f68c9..e9cbb83 100644
--- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -53,4 +53,7 @@
void setConfiguration(in ThreadConfiguration config, in IOperationReceiver receiver);
void registerConfigurationCallback(in IConfigurationReceiver receiver);
void unregisterConfigurationCallback(in IConfigurationReceiver receiver);
+
+ void activateEphemeralKeyMode(long lifetimeMillis, in IOperationReceiver receiver);
+ void deactivateEphemeralKeyMode(in IOperationReceiver receiver);
}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index cb4e8de..1222398 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -40,6 +40,7 @@
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.time.Duration;
+import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executor;
@@ -82,6 +83,25 @@
/** The Thread radio is being disabled. */
public static final int STATE_DISABLING = 2;
+ /** The ephemeral key mode is disabled. */
+ @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+ public static final int EPHEMERAL_KEY_DISABLED = 0;
+
+ /**
+ * The ephemeral key mode is enabled, an external commissioner candidate can use the ephemeral
+ * key to connect to this device and get Thread credential shared.
+ */
+ @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+ public static final int EPHEMERAL_KEY_ENABLED = 1;
+
+ /**
+ * The ephemeral key is in use. This state means there is already an active secure session
+ * connected to this device with the ephemeral key, it's not possible to use the ephemeral key
+ * for new connections in this state.
+ */
+ @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+ public static final int EPHEMERAL_KEY_IN_USE = 2;
+
/** @hide */
@Retention(RetentionPolicy.SOURCE)
@IntDef({
@@ -100,6 +120,13 @@
value = {STATE_DISABLED, STATE_ENABLED, STATE_DISABLING})
public @interface EnabledState {}
+ /** @hide */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ prefix = {"EPHEMERAL_KEY_"},
+ value = {EPHEMERAL_KEY_DISABLED, EPHEMERAL_KEY_ENABLED, EPHEMERAL_KEY_IN_USE})
+ public @interface EphemeralKeyState {}
+
/** Thread standard version 1.3. */
public static final int THREAD_VERSION_1_3 = 4;
@@ -110,6 +137,9 @@
@SuppressLint("MinMaxConstant")
public static final int MAX_POWER_CHANNEL_DISABLED = Integer.MIN_VALUE;
+ /** The maximum lifetime of an ephemeral key. @hide */
+ @NonNull private static final Duration EPHEMERAL_KEY_LIFETIME_MAX = Duration.ofMinutes(10);
+
/** @hide */
@Retention(RetentionPolicy.SOURCE)
@IntDef({THREAD_VERSION_1_3})
@@ -174,6 +204,87 @@
}
}
+ /** Returns the maximum lifetime allowed when activating ephemeral key mode. */
+ @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+ @NonNull
+ public Duration getMaxEphemeralKeyLifetime() {
+ return EPHEMERAL_KEY_LIFETIME_MAX;
+ }
+
+ /**
+ * Activates ephemeral key mode with a given {@code lifetime}. The ephemeral key is a temporary,
+ * single-use numeric code that is used for Thread Administration Sharing. After activation, the
+ * mode may expire or get deactivated, caller to this method should subscribe to the ephemeral
+ * key state updates with {@link #registerStateCallback} to get notified when the ephemeral key
+ * state changes.
+ *
+ * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The ephemeral
+ * key string contains a sequence of numeric digits 0-9 of user-input friendly length (typically
+ * 9). Subscribers to ephemeral key state updates with {@link #registerStateCallback} will be
+ * notified with a call to {@link #onEphemeralKeyStateChanged}.
+ *
+ * <p>On failure, {@link OutcomeReceiver#onError} of {@code receiver} will be invoked with a
+ * specific error:
+ *
+ * <ul>
+ * <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is not
+ * attached to Thread network
+ * <li>{@link ThreadNetworkException#ERROR_BUSY} when ephemeral key mode is already activated
+ * on the device, caller can recover from this error when the ephemeral key mode gets
+ * deactivated
+ * </ul>
+ *
+ * @param lifetime valid lifetime of the generated ephemeral key, should be larger than {@link
+ * Duration#ZERO} and at most the duration returned by {@link #getMaxEphemeralKeyLifetime}.
+ * @param executor the executor on which to execute {@code receiver}
+ * @param receiver the receiver to receive the result of this operation
+ * @throws IllegalArgumentException if the {@code lifetime} exceeds the allowed range
+ */
+ @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+ @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+ public void activateEphemeralKeyMode(
+ @NonNull Duration lifetime,
+ @NonNull @CallbackExecutor Executor executor,
+ @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+ if (lifetime.compareTo(Duration.ZERO) <= 0
+ || lifetime.compareTo(EPHEMERAL_KEY_LIFETIME_MAX) > 0) {
+ throw new IllegalArgumentException(
+ "Invalid ephemeral key lifetime: the value must be in range of (0, "
+ + EPHEMERAL_KEY_LIFETIME_MAX
+ + "]");
+ }
+ long lifetimeMillis = lifetime.toMillis();
+ try {
+ mControllerService.activateEphemeralKeyMode(
+ lifetimeMillis, new OperationReceiverProxy(executor, receiver));
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
+ /**
+ * Deactivates ephemeral key mode. If there is an active connection with the ephemeral key, the
+ * connection will be terminated.
+ *
+ * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The call will
+ * always succeed if the device is not in ephemeral key mode.
+ *
+ * @param executor the executor to execute {@code receiver}
+ * @param receiver the receiver to receive the result of this operation
+ */
+ @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+ @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+ public void deactivateEphemeralKeyMode(
+ @NonNull @CallbackExecutor Executor executor,
+ @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+ try {
+ mControllerService.deactivateEphemeralKeyMode(
+ new OperationReceiverProxy(executor, receiver));
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
/** Returns the Thread version this device is operating on. */
@ThreadVersion
public int getThreadVersion() {
@@ -248,6 +359,24 @@
* @param enabledState the new Thread enabled state
*/
default void onThreadEnableStateChanged(@EnabledState int enabledState) {}
+
+ /**
+ * The ephemeral key state has changed.
+ *
+ * @param ephemeralKeyState the ephemeral key state
+ * @param ephemeralKey the ephemeral key string which contains a sequence of numeric digits
+ * 0-9 of user-input friendly length (typically 9), or {@code null} if {@code
+ * ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED} or the caller doesn't have the
+ * permission {@link android.permission.THREAD_NETWORK_PRIVILEGED}
+ * @param expiry a timestamp of when the ephemeral key will expireor {@code null} if {@code
+ * ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED}
+ */
+ @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+ @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+ default void onEphemeralKeyStateChanged(
+ @EphemeralKeyState int ephemeralKeyState,
+ @Nullable String ephemeralKey,
+ @Nullable Instant expiry) {}
}
private static final class StateCallbackProxy extends IStateCallback.Stub {
@@ -288,13 +417,37 @@
Binder.restoreCallingIdentity(identity);
}
}
+
+ @Override
+ public void onEphemeralKeyStateChanged(
+ @EphemeralKeyState int ephemeralKeyState, String ephemeralKey, long expiryMillis) {
+ if (!Flags.epskcEnabled()) {
+ throw new IllegalStateException(
+ "This should not be called when Ephemeral key API is disabled");
+ }
+
+ final long identity = Binder.clearCallingIdentity();
+ final Instant expiry =
+ ephemeralKeyState == EPHEMERAL_KEY_DISABLED
+ ? null
+ : Instant.ofEpochMilli(expiryMillis);
+
+ try {
+ mExecutor.execute(
+ () ->
+ mCallback.onEphemeralKeyStateChanged(
+ ephemeralKeyState, ephemeralKey, expiry));
+ } finally {
+ Binder.restoreCallingIdentity(identity);
+ }
+ }
}
/**
* Registers a callback to be called when Thread network states are changed.
*
- * <p>Upon return of this method, methods of {@code callback} will be invoked immediately with
- * existing states.
+ * <p>Upon return of this method, all methods of {@code callback} will be invoked immediately
+ * with existing states. The order of the invoked callbacks is not guaranteed.
*
* @param executor the executor to execute the {@code callback}
* @param callback the callback to receive Thread network state changes
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 34aabe2..e954d3b 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -56,4 +56,14 @@
<!-- Ignores tests introduced by guava-android-testlib -->
<option name="exclude-annotation" value="org.junit.Ignore"/>
</test>
+
+ <!--
+ This doesn't override a read-only flag, to run the tests locally with `epskc_enabled` flag
+ enabled, set the flag to `is_fixed_read_only: false`. This should be removed after the
+ `epskc_enabled` flag is rolled out.
+ -->
+ <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+ <option name="flag-value"
+ value="thread_network/com.android.net.thread.flags.epskc_enabled=true"/>
+ </target_preparer>
</configuration>
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 c048394..1792bfb 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -27,11 +27,14 @@
import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_ROUTER;
import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_DISABLED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_ENABLED;
import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
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_REJECTED_BY_PEER;
import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
@@ -72,6 +75,8 @@
import android.os.HandlerThread;
import android.os.OutcomeReceiver;
import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.util.SparseIntArray;
import androidx.annotation.NonNull;
@@ -82,6 +87,8 @@
import com.android.net.thread.flags.Flags;
import com.android.testutils.FunctionalUtils.ThrowingRunnable;
+import kotlin.Triple;
+
import org.junit.After;
import org.junit.Before;
import org.junit.Ignore;
@@ -96,6 +103,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -134,9 +142,13 @@
put(VALID_CHANNEL, VALID_POWER);
}
};
+ private static final Duration EPHEMERAL_KEY_LIFETIME = Duration.ofSeconds(1);
@Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+ @Rule
+ public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
private final Context mContext = ApplicationProvider.getApplicationContext();
private ExecutorService mExecutor;
private ThreadNetworkController mController;
@@ -164,6 +176,7 @@
setEnabledAndWait(mController, true);
setConfigurationAndWait(mController, DEFAULT_CONFIG);
+ deactivateEphemeralKeyModeAndWait(mController);
}
@After
@@ -183,6 +196,7 @@
}
}
mConfigurationCallbacksToCleanUp.clear();
+ deactivateEphemeralKeyModeAndWait(mController);
}
@Test
@@ -819,6 +833,221 @@
listener.unregisterStateCallback();
}
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void getMaxEphemeralKeyLifetime_isLargerThanZero() {
+ assertThat(mController.getMaxEphemeralKeyLifetime()).isGreaterThan(Duration.ZERO);
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void activateEphemeralKeyMode_withPrivilegedPermission_succeeds() throws Exception {
+ joinRandomizedDatasetAndWait(mController);
+ CompletableFuture<Void> startFuture = new CompletableFuture<>();
+
+ runAsShell(
+ THREAD_NETWORK_PRIVILEGED,
+ () ->
+ mController.activateEphemeralKeyMode(
+ EPHEMERAL_KEY_LIFETIME,
+ mExecutor,
+ newOutcomeReceiver(startFuture)));
+
+ startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void activateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+ throws Exception {
+ dropAllPermissions();
+
+ assertThrows(
+ SecurityException.class,
+ () ->
+ mController.activateEphemeralKeyMode(
+ EPHEMERAL_KEY_LIFETIME, mExecutor, v -> {}));
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void activateEphemeralKeyMode_withZeroLifetime_throwsIllegalArgumentException()
+ throws Exception {
+ grantPermissions(THREAD_NETWORK_PRIVILEGED);
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> mController.activateEphemeralKeyMode(Duration.ZERO, mExecutor, v -> {}));
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void activateEphemeralKeyMode_withInvalidLargeLifetime_throwsIllegalArgumentException()
+ throws Exception {
+ grantPermissions(THREAD_NETWORK_PRIVILEGED);
+ Duration lifetime = mController.getMaxEphemeralKeyLifetime().plusMillis(1);
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> mController.activateEphemeralKeyMode(lifetime, Runnable::run, v -> {}));
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void activateEphemeralKeyMode_concurrentRequests_secondOneFailsWithBusyError()
+ throws Exception {
+ joinRandomizedDatasetAndWait(mController);
+ CompletableFuture<Void> future1 = new CompletableFuture<>();
+ CompletableFuture<Void> future2 = new CompletableFuture<>();
+
+ runAsShell(
+ THREAD_NETWORK_PRIVILEGED,
+ () -> {
+ mController.activateEphemeralKeyMode(
+ EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future1));
+ mController.activateEphemeralKeyMode(
+ EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future2));
+ });
+
+ var thrown =
+ assertThrows(
+ ExecutionException.class,
+ () -> {
+ future2.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+ });
+ var threadException = (ThreadNetworkException) thrown.getCause();
+ assertThat(threadException.getErrorCode()).isEqualTo(ERROR_BUSY);
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void deactivateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+ throws Exception {
+ dropAllPermissions();
+
+ assertThrows(
+ SecurityException.class,
+ () -> mController.deactivateEphemeralKeyMode(mExecutor, v -> {}));
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void subscribeEpskcState_permissionsGranted_returnsCurrentState() throws Exception {
+ CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+ CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+ CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+ StateCallback callback =
+ new ThreadNetworkController.StateCallback() {
+ @Override
+ public void onDeviceRoleChanged(int r) {}
+
+ @Override
+ public void onEphemeralKeyStateChanged(
+ int state, String ephemeralKey, Instant expiry) {
+ stateFuture.complete(state);
+ ephemeralKeyFuture.complete(ephemeralKey);
+ expiryFuture.complete(expiry);
+ }
+ };
+
+ runAsShell(
+ ACCESS_NETWORK_STATE,
+ THREAD_NETWORK_PRIVILEGED,
+ () -> mController.registerStateCallback(mExecutor, callback));
+
+ try {
+ assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+ .isEqualTo(EPHEMERAL_KEY_DISABLED);
+ assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+ assertThat(expiryFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+ } finally {
+ runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+ }
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void subscribeEpskcState_withoutThreadPriviledgedPermission_returnsNullEphemeralKey()
+ throws Exception {
+ CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+ CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+ CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+ StateCallback callback =
+ new ThreadNetworkController.StateCallback() {
+ @Override
+ public void onDeviceRoleChanged(int r) {}
+
+ @Override
+ public void onEphemeralKeyStateChanged(
+ int state, String ephemeralKey, Instant expiry) {
+ stateFuture.complete(state);
+ ephemeralKeyFuture.complete(ephemeralKey);
+ expiryFuture.complete(expiry);
+ }
+ };
+ joinRandomizedDatasetAndWait(mController);
+ activateEphemeralKeyModeAndWait(mController);
+
+ runAsShell(
+ ACCESS_NETWORK_STATE, () -> mController.registerStateCallback(mExecutor, callback));
+
+ try {
+ assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+ .isEqualTo(EPHEMERAL_KEY_ENABLED);
+ assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+ assertThat(
+ expiryFuture
+ .get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)
+ .isAfter(Instant.now()))
+ .isTrue();
+ } finally {
+ runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+ }
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void subscribeEpskcState_ephemralKeyStateChanged_returnsUpdatedState() throws Exception {
+ EphemeralKeyStateListener listener = new EphemeralKeyStateListener(mController);
+ joinRandomizedDatasetAndWait(mController);
+
+ try {
+ activateEphemeralKeyModeAndWait(mController);
+ deactivateEphemeralKeyModeAndWait(mController);
+
+ listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+ listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+ listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+ } finally {
+ listener.unregisterStateCallback();
+ }
+ }
+
+ @Test
+ @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+ public void subscribeEpskcState_epskcEnabled_returnsSameExpiry() throws Exception {
+ EphemeralKeyStateListener listener1 = new EphemeralKeyStateListener(mController);
+ Triple<Integer, String, Instant> epskc1;
+ try {
+ joinRandomizedDatasetAndWait(mController);
+ activateEphemeralKeyModeAndWait(mController);
+ epskc1 = listener1.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+ } finally {
+ listener1.unregisterStateCallback();
+ }
+
+ EphemeralKeyStateListener listener2 = new EphemeralKeyStateListener(mController);
+ try {
+ Triple<Integer, String, Instant> epskc2 =
+ listener2.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+
+ assertThat(epskc2.getSecond()).isEqualTo(epskc1.getSecond());
+ assertThat(epskc2.getThird()).isEqualTo(epskc1.getThird());
+ } finally {
+ listener2.unregisterStateCallback();
+ }
+ }
+
// TODO (b/322437869): add test case to verify when Thread is in DISABLING state, any commands
// (join/leave/scheduleMigration/setEnabled) fail with ERROR_BUSY. This is not currently tested
// because DISABLING has very short lifecycle, it's not possible to guarantee the command can be
@@ -1274,6 +1503,71 @@
setFuture.get(SET_CONFIGURATION_TIMEOUT_MILLIS, MILLISECONDS);
}
+ private void deactivateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+ throws Exception {
+ CompletableFuture<Void> clearFuture = new CompletableFuture<>();
+ runAsShell(
+ THREAD_NETWORK_PRIVILEGED,
+ () ->
+ controller.deactivateEphemeralKeyMode(
+ mExecutor, newOutcomeReceiver(clearFuture)));
+ clearFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+ }
+
+ private void activateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+ throws Exception {
+ CompletableFuture<Void> startFuture = new CompletableFuture<>();
+ runAsShell(
+ THREAD_NETWORK_PRIVILEGED,
+ () ->
+ controller.activateEphemeralKeyMode(
+ EPHEMERAL_KEY_LIFETIME,
+ mExecutor,
+ newOutcomeReceiver(startFuture)));
+ startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+ }
+
+ private class EphemeralKeyStateListener {
+ private ArrayTrackRecord<Triple<Integer, String, Instant>> mEphemeralKeyStates =
+ new ArrayTrackRecord<>();
+ private final ArrayTrackRecord<Triple<Integer, String, Instant>>.ReadHead mReadHead =
+ mEphemeralKeyStates.newReadHead();
+ ThreadNetworkController mController;
+ StateCallback mCallback =
+ new ThreadNetworkController.StateCallback() {
+ @Override
+ public void onDeviceRoleChanged(int r) {}
+
+ @Override
+ public void onEphemeralKeyStateChanged(
+ int state, String ephemeralKey, Instant expiry) {
+ mEphemeralKeyStates.add(new Triple<>(state, ephemeralKey, expiry));
+ }
+ };
+
+ EphemeralKeyStateListener(ThreadNetworkController controller) {
+ this.mController = controller;
+ runAsShell(
+ ACCESS_NETWORK_STATE,
+ THREAD_NETWORK_PRIVILEGED,
+ () -> controller.registerStateCallback(mExecutor, mCallback));
+ }
+
+ // Expect that EphemeralKey has the expected state, and return a Triple of <state,
+ // passcode, expiry>.
+ public Triple<Integer, String, Instant> expectThreadEphemeralKeyMode(int state) {
+ Triple<Integer, String, Instant> epskc =
+ mReadHead.poll(
+ ENABLED_TIMEOUT_MILLIS, e -> Objects.equals(e.getFirst(), state));
+ assertThat(epskc).isNotNull();
+ return epskc;
+ }
+
+ public void unregisterStateCallback() {
+ runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(mCallback));
+ }
+ }
+
private CompletableFuture joinRandomizedDataset(
ThreadNetworkController controller, String networkName) throws Exception {
ActiveOperationalDataset activeDataset = newRandomizedDataset(networkName, controller);