Adds a CLI (Command-Line Interface) to KitchenSink.

Examples:

$ alias ks_cmd='adb shell dumpsys activity com.google.android.car.kitchensink/.KitchenSinkActivity cmd'

$ ks_cmd help
TASK 1010085:com.google.android.car.kitchensink id=1000039 userId=10
  ACTIVITY com.google.android.car.kitchensink/.KitchenSinkActivity d39dc7c pid=10541
KitchenSink Command-Line Interface. Available commands:

  help:
    Shows this help message.

  get-delegated-scopes:
    Lists delegated scopes set by the device admin.

  is-uninstall-blocked <PKG>:
    Checks whether uninstalling the given app is blocked.

  set-uninstall-blocked <PKG> <true|false>:
    Blocks / unblocks uninstalling the given app.

  generate-device-attestation-key-pair <ALIAS> [FLAGS]:
    Generates a device attestation key.

$ ks_cmd get-delegated-scopes
TASK 1010085:com.google.android.car.kitchensink id=1000039 userId=10
  ACTIVITY com.google.android.car.kitchensink/.KitchenSinkActivity d39dc7c pid=10541
3 delegated scopes:
  delegation-block-uninstall
  delegation-package-access
  delegation-cert-install

Test: see above
Bug: 213388897

Change-Id: I58623f5c1a1d60af7743f5a815a491ebbad9b9fe
diff --git a/tests/EmbeddedKitchenSinkApp/src/com/google/android/car/kitchensink/KitchenSinkActivity.java b/tests/EmbeddedKitchenSinkApp/src/com/google/android/car/kitchensink/KitchenSinkActivity.java
index fd8819a..5574465 100644
--- a/tests/EmbeddedKitchenSinkApp/src/com/google/android/car/kitchensink/KitchenSinkActivity.java
+++ b/tests/EmbeddedKitchenSinkApp/src/com/google/android/car/kitchensink/KitchenSinkActivity.java
@@ -85,6 +85,8 @@
 import com.google.android.car.kitchensink.watchdog.CarWatchdogTestFragment;
 import com.google.android.car.kitchensink.weblinks.WebLinksTestFragment;
 
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
@@ -401,6 +403,17 @@
         super.onDestroy();
     }
 
+    @Override
+    public void dump(String prefix, FileDescriptor fd, PrintWriter writer, String[] args) {
+        if (args != null && args.length > 0 && args[0].equals("cmd")) {
+            String[] cmdArgs = new String[args.length - 1];
+            System.arraycopy(args, 1, cmdArgs, 0, args.length - 1);
+            new KitchenSinkShellCommand(this, writer, cmdArgs).run();
+            return;
+        }
+        super.dump(prefix, fd, writer, args);
+    }
+
     private void showFragment(Fragment fragment) {
         getSupportFragmentManager().beginTransaction()
                 .replace(R.id.kitchen_content, fragment)
diff --git a/tests/EmbeddedKitchenSinkApp/src/com/google/android/car/kitchensink/KitchenSinkShellCommand.java b/tests/EmbeddedKitchenSinkApp/src/com/google/android/car/kitchensink/KitchenSinkShellCommand.java
new file mode 100644
index 0000000..d72765e
--- /dev/null
+++ b/tests/EmbeddedKitchenSinkApp/src/com/google/android/car/kitchensink/KitchenSinkShellCommand.java
@@ -0,0 +1,253 @@
+/*
+ * Copyright (C) 2022 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.google.android.car.kitchensink;
+
+import android.annotation.Nullable;
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.security.AttestedKeyPair;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import android.util.IndentingPrintWriter;
+import android.util.Log;
+
+import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * {@code KitchenSink}'s own {@code cmd} implementation.
+ *
+ * <p>Usage: {$code adb shell dumpsys activity
+ * com.google.android.car.kitchensink/.KitchenSinkActivity cmd <CMD>}
+ *
+ * <p><p>Note</p>: you must launch {@code KitchenSink} first. Example: {@code
+ * adb shell am start com.google.android.car.kitchensink/.KitchenSinkActivity}
+ */
+final class KitchenSinkShellCommand {
+
+    private static final String TAG = "KitchenSinkCmd";
+
+    private static final String CMD_HELP = "help";
+    private static final String CMD_GET_DELEGATED_SCOPES = "get-delegated-scopes";
+    private static final String CMD_IS_UNINSTALL_BLOCKED = "is-uninstall-blocked";
+    private static final String CMD_SET_UNINSTALL_BLOCKED = "set-uninstall-blocked";
+    private static final String CMD_GENERATE_DEVICE_ATTESTATION_KEY_PAIR =
+            "generate-device-attestation-key-pair";
+
+    private final Context mContext;
+    private final @Nullable DevicePolicyManager mDpm;
+    private final IndentingPrintWriter mWriter;
+    private final String[] mArgs;
+
+    @Nullable // dynamically created on post() method
+    private Handler mHandler;
+
+    private int mNextArgIndex;
+
+    KitchenSinkShellCommand(Context context, PrintWriter writer, String[] args) {
+        mContext = context;
+        mDpm = context.getSystemService(DevicePolicyManager.class);
+        mWriter = new IndentingPrintWriter(writer);
+        mArgs = args;
+    }
+
+    void run() {
+        if (mArgs.length == 0) {
+            showHelp("Error: must pass an argument");
+            return;
+        }
+        String cmd = mArgs[0];
+        switch (cmd) {
+            case CMD_HELP:
+                showHelp("KitchenSink Command-Line Interface");
+                break;
+            case CMD_GET_DELEGATED_SCOPES:
+                getDelegatedScopes();
+                break;
+            case CMD_IS_UNINSTALL_BLOCKED:
+                isUninstallBlocked();
+                break;
+            case CMD_SET_UNINSTALL_BLOCKED:
+                setUninstallBlocked();
+                break;
+            case CMD_GENERATE_DEVICE_ATTESTATION_KEY_PAIR:
+                generateDeviceAttestationKeyPair();
+                break;
+            default:
+                showHelp("Invalid command: %s", cmd);
+        }
+    }
+
+    private void showHelp(String headerMessage, Object... headerArgs) {
+        if (headerMessage != null) {
+            mWriter.printf(headerMessage, headerArgs);
+            mWriter.print(". ");
+        }
+        mWriter.println("Available commands:\n");
+
+        mWriter.increaseIndent();
+        showCommandHelp("Shows this help message.",
+                CMD_HELP);
+        showCommandHelp("Lists delegated scopes set by the device admin.",
+                CMD_GET_DELEGATED_SCOPES);
+        showCommandHelp("Checks whether uninstalling the given app is blocked.",
+                CMD_IS_UNINSTALL_BLOCKED, "<PKG>");
+        showCommandHelp("Blocks / unblocks uninstalling the given app.",
+                CMD_SET_UNINSTALL_BLOCKED, "<PKG>", "<true|false>");
+        showCommandHelp("Generates a device attestation key.",
+                CMD_GENERATE_DEVICE_ATTESTATION_KEY_PAIR, "<ALIAS>", "[FLAGS]");
+        mWriter.decreaseIndent();
+    }
+
+    private void showCommandHelp(String description, String cmd, String... args) {
+        mWriter.printf("%s", cmd);
+        if (args != null) {
+            for (String arg : args) {
+                mWriter.printf(" %s", arg);
+            }
+        }
+        mWriter.println(":");
+        mWriter.increaseIndent();
+        mWriter.printf("%s\n\n", description);
+        mWriter.decreaseIndent();
+    }
+
+    private void getDelegatedScopes() {
+        if (!supportDevicePolicyManagement()) return;
+
+        List<String> scopes = mDpm.getDelegatedScopes(/* admin= */ null, mContext.getPackageName());
+        printCollection("delegated scope", scopes);
+    }
+
+    private void isUninstallBlocked() {
+        if (!supportDevicePolicyManagement()) return;
+
+        String packageName = getNextArg();
+        boolean isIt = mDpm.isUninstallBlocked(/* admin= */ null, packageName);
+        mWriter.println(isIt);
+    }
+
+    private void setUninstallBlocked() {
+        if (!supportDevicePolicyManagement()) return;
+
+        String packageName = getNextArg();
+        boolean blocked = getNextBooleanArg();
+
+        Log.i(TAG, "Calling dpm.setUninstallBlocked(" + packageName + ", " + blocked + ")");
+        mDpm.setUninstallBlocked(/* admin= */ null, packageName, blocked);
+    }
+
+    private void generateDeviceAttestationKeyPair() {
+        if (!supportDevicePolicyManagement()) return;
+
+        String alias = getNextArg();
+        int flags = getNextOptionalIntArg(/* defaultValue= */ 0);
+        // Cannot call dpm.generateKeyPair() on main thread
+        warnAboutAsyncCall();
+        post(() -> handleDeviceAttestationKeyPair(alias, flags));
+    }
+
+    private void handleDeviceAttestationKeyPair(String alias, int flags) {
+        KeyGenParameterSpec keySpec = buildRsaKeySpecWithKeyAttestation(alias);
+        String algorithm = "RSA";
+        Log.i(TAG, "calling dpm.generateKeyPair(alg=" + algorithm + ", spec=" + keySpec
+                + ", flags=" + flags + ")");
+        AttestedKeyPair kp = mDpm.generateKeyPair(/* admin= */ null, algorithm, keySpec, flags);
+        Log.i(TAG, "key: " + kp);
+    }
+
+    private void warnAboutAsyncCall() {
+        mWriter.printf("Command will be executed asynchronally; use `adb logcat %s *:s` for result"
+                + "\n", TAG);
+    }
+
+    private void post(Runnable r) {
+        if (mHandler == null) {
+            HandlerThread handlerThread = new HandlerThread("KitchenSinkShellCommandThread");
+            Log.i(TAG, "Starting " + handlerThread);
+            handlerThread.start();
+            mHandler = new Handler(handlerThread.getLooper());
+        }
+        Log.d(TAG, "posting runnable");
+        mHandler.post(r);
+    }
+
+    private boolean supportDevicePolicyManagement() {
+        if (mDpm == null) {
+            mWriter.println("Device Policy Management not supported by device");
+            return false;
+        }
+        return true;
+    }
+
+    private String getNextArgAndIncrementCounter() {
+        return mArgs[++mNextArgIndex];
+    }
+
+    private String getNextArg() {
+        try {
+            return getNextArgAndIncrementCounter();
+        } catch (Exception e) {
+            mWriter.println("Error: missing argument");
+            mWriter.flush();
+            throw new IllegalArgumentException(
+                    "Missing argument. Args=" + Arrays.toString(mArgs));
+        }
+    }
+
+    private int getNextOptionalIntArg(int defaultValue) {
+        try {
+            return Integer.parseInt(getNextArgAndIncrementCounter());
+        } catch (Exception e) {
+            Log.d(TAG, "Exception getting optional arg: " + e);
+            return defaultValue;
+        }
+    }
+
+    private boolean getNextBooleanArg() {
+        String arg = getNextArg();
+        return Boolean.parseBoolean(arg);
+    }
+
+    private void printCollection(String nameOnSingular, Collection<String> collection) {
+        if (collection.isEmpty()) {
+            mWriter.printf("No %ss\n", nameOnSingular);
+            return;
+        }
+        int size = collection.size();
+        mWriter.printf("%d %s%s:\n", size, nameOnSingular, size == 1 ? "" : "s");
+        collection.forEach((s) -> mWriter.printf("  %s\n", s));
+    }
+
+    // Copied from CTS' KeyGenerationUtils
+    private static KeyGenParameterSpec buildRsaKeySpecWithKeyAttestation(String alias) {
+        return new KeyGenParameterSpec.Builder(alias,
+                KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_VERIFY)
+                        .setKeySize(2048)
+                        .setDigests(KeyProperties.DIGEST_SHA256)
+                        .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PSS,
+                                KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
+                        .setIsStrongBoxBacked(false)
+                        .setAttestationChallenge(new byte[] {
+                                'a', 'b', 'c'
+                        })
+                        .build();
+    }
+}