Allow ot-ctl command under ADB Thread Network Service : support of non-interactive mode
Bug: 351721329
Test: atest ThreadNetworkUnitTests
Change-Id: Id7443c24ea380d200e73cca4ead2284249f2052f
diff --git a/thread/framework/java/android/net/thread/IOutputReceiver.aidl b/thread/framework/java/android/net/thread/IOutputReceiver.aidl
new file mode 100644
index 0000000..b6b4375
--- /dev/null
+++ b/thread/framework/java/android/net/thread/IOutputReceiver.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 output of a Thread network operation. @hide */
+oneway interface IOutputReceiver {
+ void onOutput(in String output);
+ void onComplete();
+ void onError(int errorCode, String errorMessage);
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
index bca8b6e..b863bc2 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -81,6 +81,19 @@
"android.permission.THREAD_NETWORK_PRIVILEGED";
/**
+ * Permission allows accessing Thread network state and performing certain testing-related
+ * operations.
+ *
+ * <p>This is the same value as android.Manifest.permission.THREAD_NETWORK_TESTING. 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_TESTING.
+ *
+ * @hide
+ */
+ public static final String PERMISSION_THREAD_NETWORK_TESTING =
+ "android.permission.THREAD_NETWORK_TESTING";
+
+ /**
* This user restriction specifies if Thread network is disallowed on the device. If Thread
* network is disallowed it cannot be turned on via Settings.
*
diff --git a/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
new file mode 100644
index 0000000..aa9a05d
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
@@ -0,0 +1,120 @@
+/*
+ * 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_INTERNAL_ERROR;
+import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
+
+import android.net.thread.IOutputReceiver;
+import android.net.thread.ThreadNetworkException;
+import android.os.RemoteException;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/** A {@link IOutputReceiver} wrapper which makes it easier to invoke the callbacks. */
+final class OutputReceiverWrapper {
+ private final IOutputReceiver mReceiver;
+ private final boolean mExpectOtDaemonDied;
+
+ private static final Object sPendingReceiversLock = new Object();
+
+ @GuardedBy("sPendingReceiversLock")
+ private static final Set<OutputReceiverWrapper> sPendingReceivers = new HashSet<>();
+
+ public OutputReceiverWrapper(IOutputReceiver receiver) {
+ this(receiver, false /* expectOtDaemonDied */);
+ }
+
+ /**
+ * Creates a new {@link OutputReceiverWrapper}.
+ *
+ * <p>If {@code expectOtDaemonDied} is {@code true}, it's expected that ot-daemon becomes dead
+ * before {@code receiver} is completed with {@code onComplete} and {@code onError} and {@code
+ * receiver#onComplete} will be invoked in this case.
+ */
+ public OutputReceiverWrapper(IOutputReceiver receiver, boolean expectOtDaemonDied) {
+ mReceiver = receiver;
+ mExpectOtDaemonDied = expectOtDaemonDied;
+
+ synchronized (sPendingReceiversLock) {
+ sPendingReceivers.add(this);
+ }
+ }
+
+ public static void onOtDaemonDied() {
+ synchronized (sPendingReceiversLock) {
+ for (OutputReceiverWrapper receiver : sPendingReceivers) {
+ try {
+ if (receiver.mExpectOtDaemonDied) {
+ receiver.mReceiver.onComplete();
+ } else {
+ receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died");
+ }
+ } catch (RemoteException e) {
+ // The client is dead, do nothing
+ }
+ }
+ sPendingReceivers.clear();
+ }
+ }
+
+ public void onOutput(String output) {
+ try {
+ mReceiver.onOutput(output);
+ } catch (RemoteException e) {
+ // The client is dead, do nothing
+ }
+ }
+
+ public void onComplete() {
+ synchronized (sPendingReceiversLock) {
+ sPendingReceivers.remove(this);
+ }
+
+ try {
+ mReceiver.onComplete();
+ } catch (RemoteException e) {
+ // The client is dead, do nothing
+ }
+ }
+
+ public void onError(Throwable e) {
+ if (e instanceof ThreadNetworkException) {
+ ThreadNetworkException threadException = (ThreadNetworkException) e;
+ onError(threadException.getErrorCode(), threadException.getMessage());
+ } else if (e instanceof RemoteException) {
+ onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+ } else {
+ throw new AssertionError(e);
+ }
+ }
+
+ 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 362ca7e..c0993f0 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -43,6 +43,7 @@
import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_ABORT;
import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_BUSY;
@@ -94,6 +95,7 @@
import android.net.thread.IConfigurationReceiver;
import android.net.thread.IOperationReceiver;
import android.net.thread.IOperationalDatasetCallback;
+import android.net.thread.IOutputReceiver;
import android.net.thread.IStateCallback;
import android.net.thread.IThreadNetworkController;
import android.net.thread.OperationalDatasetTimestamp;
@@ -124,6 +126,7 @@
import com.android.server.thread.openthread.IChannelMasksReceiver;
import com.android.server.thread.openthread.IOtDaemon;
import com.android.server.thread.openthread.IOtDaemonCallback;
+import com.android.server.thread.openthread.IOtOutputReceiver;
import com.android.server.thread.openthread.IOtStatusReceiver;
import com.android.server.thread.openthread.InfraLinkState;
import com.android.server.thread.openthread.Ipv6AddressInfo;
@@ -426,6 +429,7 @@
LOG.w("OT daemon is dead, clean up...");
OperationReceiverWrapper.onOtDaemonDied();
+ OutputReceiverWrapper.onOtDaemonDied();
mOtDaemonCallbackProxy.onOtDaemonDied();
mTunIfController.onOtDaemonDied();
mNsdPublisher.onOtDaemonDied();
@@ -1042,6 +1046,25 @@
};
}
+ private IOtOutputReceiver newOtOutputReceiver(OutputReceiverWrapper receiver) {
+ return new IOtOutputReceiver.Stub() {
+ @Override
+ public void onOutput(String output) {
+ receiver.onOutput(output);
+ }
+
+ @Override
+ public void onComplete() {
+ receiver.onComplete();
+ }
+
+ @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
@@ -1318,6 +1341,31 @@
}
}
+ @RequiresPermission(
+ allOf = {PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING})
+ public void runOtCtlCommand(
+ @NonNull String command, boolean isInteractive, @NonNull IOutputReceiver receiver) {
+ enforceAllPermissionsGranted(
+ PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING);
+
+ mHandler.post(
+ () ->
+ runOtCtlCommandInternal(
+ command, isInteractive, new OutputReceiverWrapper(receiver)));
+ }
+
+ private void runOtCtlCommandInternal(
+ String command, boolean isInteractive, @NonNull OutputReceiverWrapper receiver) {
+ checkOnHandlerThread();
+
+ try {
+ getOtDaemon().runOtCtlCommand(command, isInteractive, newOtOutputReceiver(receiver));
+ } catch (RemoteException | ThreadNetworkException e) {
+ LOG.e("otDaemon.runOtCtlCommand failed", e);
+ receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+ }
+ }
+
private void sendLocalNetworkConfig() {
if (mNetworkAgent == null) {
return;
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
index 54155ee..1eddebf 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -20,9 +20,12 @@
import android.content.Context;
import android.net.thread.ActiveOperationalDataset;
import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
import android.net.thread.OperationalDatasetTimestamp;
import android.net.thread.PendingOperationalDataset;
import android.net.thread.ThreadNetworkException;
+import android.os.Binder;
+import android.os.Process;
import android.text.TextUtils;
import com.android.internal.annotations.VisibleForTesting;
@@ -52,6 +55,7 @@
private static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
private static final Duration MIGRATE_TIMEOUT = Duration.ofSeconds(2);
private static final Duration FORCE_STOP_TIMEOUT = Duration.ofSeconds(1);
+ private static final Duration OT_CTL_COMMAND_TIMEOUT = Duration.ofSeconds(5);
private static final String PERMISSION_THREAD_NETWORK_TESTING =
"android.permission.THREAD_NETWORK_TESTING";
@@ -62,7 +66,8 @@
@Nullable private PrintWriter mOutputWriter;
@Nullable private PrintWriter mErrorWriter;
- public ThreadNetworkShellCommand(
+ @VisibleForTesting
+ ThreadNetworkShellCommand(
Context context,
ThreadNetworkControllerService controllerService,
ThreadNetworkCountryCode countryCode) {
@@ -77,6 +82,10 @@
mErrorWriter = errorWriter;
}
+ private static boolean isRootProcess() {
+ return Binder.getCallingUid() == Process.ROOT_UID;
+ }
+
private PrintWriter getOutputWriter() {
return (mOutputWriter != null) ? mOutputWriter : getOutPrintWriter();
}
@@ -107,6 +116,8 @@
pw.println(" Gets country code as a two-letter string");
pw.println(" force-country-code enabled <two-letter code> | disabled ");
pw.println(" Sets country code to <two-letter code> or left for normal value");
+ pw.println(" ot-ctl <subcommand>");
+ pw.println(" Runs ot-ctl command");
}
@Override
@@ -133,6 +144,8 @@
return forceCountryCode();
case "get-country-code":
return getCountryCode();
+ case "ot-ctl":
+ return handleOtCtlCommand();
default:
return handleDefaultCommands(cmd);
}
@@ -248,6 +261,50 @@
return 0;
}
+ private static final class OutputReceiver extends IOutputReceiver.Stub {
+ private final CompletableFuture<Void> future;
+ private final PrintWriter outputWriter;
+
+ public OutputReceiver(CompletableFuture<Void> future, PrintWriter outputWriter) {
+ this.future = future;
+ this.outputWriter = outputWriter;
+ }
+
+ @Override
+ public void onOutput(String output) {
+ outputWriter.print(output);
+ outputWriter.flush();
+ }
+
+ @Override
+ public void onComplete() {
+ future.complete(null);
+ }
+
+ @Override
+ public void onError(int errorCode, String errorMessage) {
+ future.completeExceptionally(new ThreadNetworkException(errorCode, errorMessage));
+ }
+ }
+
+ private int handleOtCtlCommand() {
+ ensureTestingPermission();
+
+ if (!isRootProcess()) {
+ getErrorWriter().println("No access to ot-ctl command");
+ return -1;
+ }
+
+ final String subCommand = String.join(" ", peekRemainingArgs());
+
+ CompletableFuture<Void> completeFuture = new CompletableFuture<>();
+ mControllerService.runOtCtlCommand(
+ subCommand,
+ false /* isInteractive */,
+ new OutputReceiver(completeFuture, getOutputWriter()));
+ return waitForFuture(completeFuture, OT_CTL_COMMAND_TIMEOUT, getErrorWriter());
+ }
+
private static IOperationReceiver newOperationReceiver(CompletableFuture<Void> future) {
return new IOperationReceiver.Stub() {
@Override
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 8835f40..87219d3 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -19,14 +19,18 @@
import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
+import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import android.content.Context;
+import android.net.thread.utils.FullThreadDevice;
+import android.net.thread.utils.OtDaemonController;
import android.net.thread.utils.ThreadFeatureCheckerRule;
import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
import android.net.thread.utils.ThreadNetworkControllerWrapper;
@@ -41,6 +45,9 @@
import org.junit.Test;
import org.junit.runner.RunWith;
+import java.net.Inet6Address;
+import java.time.Duration;
+import java.util.List;
import java.util.concurrent.ExecutionException;
/** Integration tests for {@link ThreadNetworkShellCommand}. */
@@ -53,14 +60,24 @@
private final Context mContext = ApplicationProvider.getApplicationContext();
private final ThreadNetworkControllerWrapper mController =
ThreadNetworkControllerWrapper.newInstance(mContext);
+ private final OtDaemonController mOtCtl = new OtDaemonController();
+ private FullThreadDevice mFtd;
@Before
- public void setUp() {
+ public void setUp() throws Exception {
+ // TODO(b/366141754): The current implementation of "thread_network ot-ctl factoryreset"
+ // results in timeout error.
+ // A future fix will provide proper support for factoryreset, allowing us to replace the
+ // legacy "ot-ctl".
+ mOtCtl.factoryReset();
+
+ mFtd = new FullThreadDevice(10 /* nodeId */);
ensureThreadEnabled();
}
@After
- public void tearDown() {
+ public void tearDown() throws Exception {
+ mFtd.destroy();
ensureThreadEnabled();
}
@@ -69,6 +86,13 @@
runThreadCommand("enable");
}
+ private static void startFtdChild(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+ throws Exception {
+ ftd.factoryReset();
+ ftd.joinNetwork(activeDataset);
+ ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
+ }
+
@Test
public void enable_threadStateIsEnabled() throws Exception {
runThreadCommand("enable");
@@ -123,6 +147,38 @@
assertThat(result).contains("Thread country code = CN");
}
+ @Test
+ public void handleOtCtlCommand_enableIfconfig_getIfconfigReturnsUP() {
+ runThreadCommand("ot-ctl ifconfig up");
+
+ final String result = runThreadCommand("ot-ctl ifconfig");
+
+ assertThat(result).isEqualTo("up\r\nDone\r\n");
+ }
+
+ @Test
+ public void handleOtCtlCommand_disableIfconfig_startThreadFailsWithInvalidState() {
+ runThreadCommand("ot-ctl ifconfig down");
+
+ final String result = runThreadCommand("ot-ctl thread start");
+
+ assertThat(result).isEqualTo("Error 13: InvalidState\r\n");
+ }
+
+ @Test
+ public void handleOtCtlCommand_pingFtd_getValidResponse() throws Exception {
+ mController.joinAndWait(DEFAULT_DATASET);
+ startFtdChild(mFtd, DEFAULT_DATASET);
+ final Inet6Address ftdMlEid = mFtd.getMlEid();
+ assertNotNull(ftdMlEid);
+
+ final String result = runThreadCommand("ot-ctl ping " + ftdMlEid.getHostAddress());
+
+ assertThat(result).contains("1 packets transmitted, 1 packets received");
+ assertThat(result).contains("Packet loss = 0.0%");
+ assertThat(result).endsWith("Done\r\n");
+ }
+
private static String runThreadCommand(String cmd) {
return runShellCommandOrThrow("cmd thread_network " + cmd);
}
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index be32764..f7f1c3f 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -30,6 +30,7 @@
import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
@@ -69,6 +70,7 @@
import android.net.thread.ActiveOperationalDataset;
import android.net.thread.IActiveOperationalDatasetReceiver;
import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
import android.net.thread.ThreadConfiguration;
import android.net.thread.ThreadNetworkException;
import android.os.Handler;
@@ -192,6 +194,9 @@
eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), anyString());
doNothing()
.when(mContext)
+ .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), anyString());
+ doNothing()
+ .when(mContext)
.enforceCallingOrSelfPermission(eq(NETWORK_SETTINGS), anyString());
mTestLooper = new TestLooper();
@@ -801,4 +806,31 @@
assertThat(networkRequest2.getNetworkSpecifier()).isNull();
assertThat(networkRequest2.hasCapability(NET_CAPABILITY_NOT_VPN)).isTrue();
}
+
+ @Test
+ public void runOtCtlCommand_noPermission_throwsSecurityException() {
+ doThrow(new SecurityException(""))
+ .when(mContext)
+ .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), any());
+ doThrow(new SecurityException(""))
+ .when(mContext)
+ .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), any());
+
+ assertThrows(
+ SecurityException.class,
+ () -> mService.runOtCtlCommand("", false, new IOutputReceiver.Default()));
+ }
+
+ @Test
+ public void runOtCtlCommand_otDaemonRemoteFailure_receiverOnErrorIsCalled() throws Exception {
+ mService.initialize();
+ final IOutputReceiver mockReceiver = mock(IOutputReceiver.class);
+ mFakeOtDaemon.setRunOtCtlCommandException(
+ new RemoteException("ot-daemon runOtCtlCommand() throws"));
+
+ mService.runOtCtlCommand("ot-ctl state", false, mockReceiver);
+ mTestLooper.dispatchAll();
+
+ verify(mockReceiver, times(1)).onError(eq(ERROR_INTERNAL_ERROR), anyString());
+ }
}
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
index dfb3129..af5c9aa 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -20,12 +20,15 @@
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.contains;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
@@ -35,8 +38,10 @@
import android.content.Context;
import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IOutputReceiver;
import android.net.thread.PendingOperationalDataset;
import android.os.Binder;
+import android.os.Process;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.SmallTest;
@@ -47,6 +52,7 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@@ -95,6 +101,9 @@
mShellCommand = new ThreadNetworkShellCommand(mContext, mControllerService, mCountryCode);
mShellCommand.setPrintWriters(mOutputWriter, mErrorWriter);
+
+ // by default emulate shell uid.
+ BinderUtil.setUid(Process.SHELL_UID);
}
@After
@@ -102,16 +111,20 @@
validateMockitoUsage();
}
- @Test
- public void getCountryCode_testingPermissionIsChecked() {
- when(mCountryCode.getCountryCode()).thenReturn("US");
-
+ private void runShellCommand(String... args) {
mShellCommand.exec(
new Binder(),
new FileDescriptor(),
new FileDescriptor(),
new FileDescriptor(),
- new String[] {"get-country-code"});
+ args);
+ }
+
+ @Test
+ public void getCountryCode_testingPermissionIsChecked() {
+ when(mCountryCode.getCountryCode()).thenReturn("US");
+
+ runShellCommand("get-country-code");
verify(mContext, times(1))
.enforceCallingOrSelfPermission(
@@ -122,24 +135,14 @@
public void getCountryCode_currentCountryCodePrinted() {
when(mCountryCode.getCountryCode()).thenReturn("US");
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"get-country-code"});
+ runShellCommand("get-country-code");
verify(mOutputWriter).println(contains("US"));
}
@Test
public void forceSetCountryCodeEnabled_testingPermissionIsChecked() {
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"force-country-code", "enabled", "US"});
+ runShellCommand("force-country-code", "enabled", "US");
verify(mContext, times(1))
.enforceCallingOrSelfPermission(
@@ -148,36 +151,21 @@
@Test
public void forceSetCountryCodeEnabled_countryCodeIsOverridden() {
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"force-country-code", "enabled", "US"});
+ runShellCommand("force-country-code", "enabled", "US");
verify(mCountryCode).setOverrideCountryCode(eq("US"));
}
@Test
public void forceSetCountryCodeDisabled_overriddenCountryCodeIsCleared() {
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"force-country-code", "disabled"});
+ runShellCommand("force-country-code", "disabled");
verify(mCountryCode).clearOverrideCountryCode();
}
@Test
public void forceStopOtDaemon_testingPermissionIsChecked() {
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"force-stop-ot-daemon", "enabled"});
+ runShellCommand("force-stop-ot-daemon", "enabled");
verify(mContext, times(1))
.enforceCallingOrSelfPermission(
@@ -190,12 +178,7 @@
.when(mControllerService)
.forceStopOtDaemonForTest(eq(true), any());
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"force-stop-ot-daemon", "enabled"});
+ runShellCommand("force-stop-ot-daemon", "enabled");
verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
verify(mOutputWriter, never()).println();
@@ -205,12 +188,7 @@
public void forceStopOtDaemon_serviceApiTimeout_failedWithTimeoutError() {
doNothing().when(mControllerService).forceStopOtDaemonForTest(eq(true), any());
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"force-stop-ot-daemon", "enabled"});
+ runShellCommand("force-stop-ot-daemon", "enabled");
verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
@@ -221,12 +199,7 @@
public void join_controllerServiceJoinIsCalled() {
doNothing().when(mControllerService).join(any(), any());
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"join", DEFAULT_ACTIVE_DATASET_TLVS});
+ runShellCommand("join", DEFAULT_ACTIVE_DATASET_TLVS);
var activeDataset =
ActiveOperationalDataset.fromThreadTlvs(
@@ -239,12 +212,7 @@
public void join_invalidDataset_controllerServiceJoinIsNotCalled() {
doNothing().when(mControllerService).join(any(), any());
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"join", "000102"});
+ runShellCommand("join", "000102");
verify(mControllerService, never()).join(any(), any());
verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -254,12 +222,7 @@
public void migrate_controllerServiceMigrateIsCalled() {
doNothing().when(mControllerService).scheduleMigration(any(), any());
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300"});
+ runShellCommand("migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300");
ArgumentCaptor<PendingOperationalDataset> captor =
ArgumentCaptor.forClass(PendingOperationalDataset.class);
@@ -276,12 +239,7 @@
public void migrate_invalidDataset_controllerServiceMigrateIsNotCalled() {
doNothing().when(mControllerService).scheduleMigration(any(), any());
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"migrate", "000102", "300"});
+ runShellCommand("migrate", "000102", "300");
verify(mControllerService, never()).scheduleMigration(any(), any());
verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -291,14 +249,75 @@
public void leave_controllerServiceLeaveIsCalled() {
doNothing().when(mControllerService).leave(any());
- mShellCommand.exec(
- new Binder(),
- new FileDescriptor(),
- new FileDescriptor(),
- new FileDescriptor(),
- new String[] {"leave"});
+ runShellCommand("leave");
verify(mControllerService, times(1)).leave(any());
verify(mErrorWriter, never()).println();
}
+
+ @Test
+ public void handleOtCtlCommand_testingPermissionIsChecked() {
+ BinderUtil.setUid(Process.ROOT_UID);
+ doAnswer(
+ invocation -> {
+ IOutputReceiver receiver = invocation.getArgument(1);
+ receiver.onComplete();
+ return null;
+ })
+ .when(mControllerService)
+ .runOtCtlCommand(anyString(), anyBoolean(), any());
+
+ runShellCommand("ot-ctl", "state");
+
+ verify(mContext, times(1))
+ .enforceCallingOrSelfPermission(
+ eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+ }
+
+ @Test
+ public void handleOtCtlCommand_failsWithNonRootProcess() {
+ runShellCommand("ot-ctl", "state");
+
+ verify(mErrorWriter, times(1)).println(contains("No access to ot-ctl command"));
+ verify(mOutputWriter, never()).println();
+ }
+
+ @Test
+ public void handleOtCtlCommand_nonInteractive_serviceTimeout_failsWithTimeoutError() {
+ BinderUtil.setUid(Process.ROOT_UID);
+ doNothing().when(mControllerService).runOtCtlCommand(anyString(), eq(false), any());
+
+ runShellCommand("ot-ctl", "state");
+
+ verify(mControllerService, times(1)).runOtCtlCommand(anyString(), eq(false), any());
+ verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+ verify(mOutputWriter, never()).println();
+ }
+
+ @Test
+ public void handleOtCtlCommand_nonInteractive_state_outputIsPrinted() {
+ BinderUtil.setUid(Process.ROOT_UID);
+ doAnswer(
+ invocation -> {
+ IOutputReceiver receiver = invocation.getArgument(2);
+
+ receiver.onOutput("leader");
+ receiver.onOutput("\r\n");
+ receiver.onOutput("Done");
+ receiver.onOutput("\r\n");
+
+ receiver.onComplete();
+ return null;
+ })
+ .when(mControllerService)
+ .runOtCtlCommand(eq("state"), eq(false), any());
+
+ runShellCommand("ot-ctl", "state");
+
+ InOrder inOrder = inOrder(mOutputWriter);
+ inOrder.verify(mOutputWriter).print("leader");
+ inOrder.verify(mOutputWriter).print("\r\n");
+ inOrder.verify(mOutputWriter).print("Done");
+ inOrder.verify(mOutputWriter).print("\r\n");
+ }
}