summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/src/com/android/systemui/ScreenDecorHwcLayer.kt32
-rw-r--r--packages/SystemUI/src/com/android/systemui/ScreenDecorations.java54
-rw-r--r--packages/SystemUI/src/com/android/systemui/decor/DebugRoundedCornerDelegate.kt30
-rw-r--r--packages/SystemUI/src/com/android/systemui/decor/ScreenDecorCommand.kt171
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt327
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt195
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt395
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt106
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt173
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/ScreenDecorationsTest.java13
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt212
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt55
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt317
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt61
14 files changed, 2120 insertions, 21 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/ScreenDecorHwcLayer.kt b/packages/SystemUI/src/com/android/systemui/ScreenDecorHwcLayer.kt
index 670c1fa45e5c..c46558532372 100644
--- a/packages/SystemUI/src/com/android/systemui/ScreenDecorHwcLayer.kt
+++ b/packages/SystemUI/src/com/android/systemui/ScreenDecorHwcLayer.kt
@@ -56,9 +56,14 @@ class ScreenDecorHwcLayer(
) : DisplayCutoutBaseView(context) {
val colorMode: Int
private val useInvertedAlphaColor: Boolean
- private val color: Int
+ private var color: Int = Color.BLACK
+ set(value) {
+ field = value
+ paint.color = value
+ }
+
private val bgColor: Int
- private val cornerFilter: ColorFilter
+ private var cornerFilter: ColorFilter
private val cornerBgFilter: ColorFilter
private val clearPaint: Paint
@JvmField val transparentRect: Rect = Rect()
@@ -109,10 +114,16 @@ class ScreenDecorHwcLayer(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
parent.requestTransparentRegion(this)
+ updateColors()
+ }
+
+ private fun updateColors() {
if (!debug) {
viewRootImpl.setDisplayDecoration(true)
}
+ cornerFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
+
if (useInvertedAlphaColor) {
paint.set(clearPaint)
} else {
@@ -121,6 +132,21 @@ class ScreenDecorHwcLayer(
}
}
+ fun setDebugColor(color: Int) {
+ if (!debug) {
+ return
+ }
+
+ if (this.color == color) {
+ return
+ }
+
+ this.color = color
+
+ updateColors()
+ invalidate()
+ }
+
override fun onUpdate() {
parent.requestTransparentRegion(this)
}
@@ -367,7 +393,7 @@ class ScreenDecorHwcLayer(
/**
* Update the rounded corner drawables.
*/
- fun updateRoundedCornerDrawable(top: Drawable, bottom: Drawable) {
+ fun updateRoundedCornerDrawable(top: Drawable?, bottom: Drawable?) {
roundedCornerDrawableTop = top
roundedCornerDrawableBottom = bottom
updateRoundedCornerDrawableBounds()
diff --git a/packages/SystemUI/src/com/android/systemui/ScreenDecorations.java b/packages/SystemUI/src/com/android/systemui/ScreenDecorations.java
index 67d4a2e25051..de7a66900355 100644
--- a/packages/SystemUI/src/com/android/systemui/ScreenDecorations.java
+++ b/packages/SystemUI/src/com/android/systemui/ScreenDecorations.java
@@ -69,9 +69,9 @@ import com.android.internal.util.Preconditions;
import com.android.settingslib.Utils;
import com.android.systemui.biometrics.AuthController;
import com.android.systemui.dagger.SysUISingleton;
-import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.decor.CutoutDecorProviderFactory;
import com.android.systemui.decor.DebugRoundedCornerDelegate;
+import com.android.systemui.decor.DebugRoundedCornerModel;
import com.android.systemui.decor.DecorProvider;
import com.android.systemui.decor.DecorProviderFactory;
import com.android.systemui.decor.DecorProviderKt;
@@ -80,10 +80,12 @@ import com.android.systemui.decor.OverlayWindow;
import com.android.systemui.decor.PrivacyDotDecorProviderFactory;
import com.android.systemui.decor.RoundedCornerDecorProviderFactory;
import com.android.systemui.decor.RoundedCornerResDelegateImpl;
+import com.android.systemui.decor.ScreenDecorCommand;
import com.android.systemui.log.ScreenDecorationsLogger;
import com.android.systemui.qs.SettingObserver;
import com.android.systemui.settings.DisplayTracker;
import com.android.systemui.settings.UserTracker;
+import com.android.systemui.statusbar.commandline.CommandRegistry;
import com.android.systemui.statusbar.events.PrivacyDotViewController;
import com.android.systemui.util.concurrency.DelayableExecutor;
import com.android.systemui.util.concurrency.ThreadFactory;
@@ -95,7 +97,6 @@ import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
-import java.util.concurrent.Executor;
import javax.inject.Inject;
@@ -130,7 +131,7 @@ public class ScreenDecorations implements CoreStartable, Dumpable {
@VisibleForTesting
protected boolean mIsRegistered;
private final Context mContext;
- private final Executor mMainExecutor;
+ private final CommandRegistry mCommandRegistry;
private final SecureSettings mSecureSettings;
@VisibleForTesting
DisplayTracker.Callback mDisplayListener;
@@ -313,8 +314,8 @@ public class ScreenDecorations implements CoreStartable, Dumpable {
@Inject
public ScreenDecorations(Context context,
- @Main Executor mainExecutor,
SecureSettings secureSettings,
+ CommandRegistry commandRegistry,
UserTracker userTracker,
DisplayTracker displayTracker,
PrivacyDotViewController dotViewController,
@@ -324,8 +325,8 @@ public class ScreenDecorations implements CoreStartable, Dumpable {
ScreenDecorationsLogger logger,
AuthController authController) {
mContext = context;
- mMainExecutor = mainExecutor;
mSecureSettings = secureSettings;
+ mCommandRegistry = commandRegistry;
mUserTracker = userTracker;
mDisplayTracker = displayTracker;
mDotViewController = dotViewController;
@@ -350,6 +351,45 @@ public class ScreenDecorations implements CoreStartable, Dumpable {
}
};
+ private final ScreenDecorCommand.Callback mScreenDecorCommandCallback = (cmd, pw) -> {
+ // If we are exiting debug mode, we can set it (false) and bail, otherwise we will
+ // ensure that debug mode is set
+ if (cmd.getDebug() != null && !cmd.getDebug()) {
+ setDebug(false);
+ return;
+ } else {
+ // setDebug is idempotent
+ setDebug(true);
+ }
+
+ if (cmd.getColor() != null) {
+ mDebugColor = cmd.getColor();
+ mExecutor.execute(() -> {
+ if (mScreenDecorHwcLayer != null) {
+ mScreenDecorHwcLayer.setDebugColor(cmd.getColor());
+ }
+ updateColorInversionDefault();
+ });
+ }
+
+ DebugRoundedCornerModel roundedTop = null;
+ DebugRoundedCornerModel roundedBottom = null;
+ if (cmd.getRoundedTop() != null) {
+ roundedTop = cmd.getRoundedTop().toRoundedCornerDebugModel();
+ }
+ if (cmd.getRoundedBottom() != null) {
+ roundedBottom = cmd.getRoundedBottom().toRoundedCornerDebugModel();
+ }
+ if (roundedTop != null || roundedBottom != null) {
+ mDebugRoundedCornerDelegate.applyNewDebugCorners(roundedTop, roundedBottom);
+ mExecutor.execute(() -> {
+ removeAllOverlays();
+ removeHwcOverlay();
+ setupDecorations();
+ });
+ }
+ };
+
@Override
public void start() {
if (DEBUG_DISABLE_SCREEN_DECORATIONS) {
@@ -361,6 +401,8 @@ public class ScreenDecorations implements CoreStartable, Dumpable {
mExecutor.execute(this::startOnScreenDecorationsThread);
mDotViewController.setUiExecutor(mExecutor);
mAuthController.addCallback(mAuthControllerCallback);
+ mCommandRegistry.registerCommand(ScreenDecorCommand.SCREEN_DECOR_CMD_NAME,
+ () -> new ScreenDecorCommand(mScreenDecorCommandCallback));
}
/**
@@ -1228,7 +1270,7 @@ public class ScreenDecorations implements CoreStartable, Dumpable {
bottomDrawable = mDebugRoundedCornerDelegate.getBottomRoundedDrawable();
}
- if (topDrawable == null || bottomDrawable == null) {
+ if (topDrawable == null && bottomDrawable == null) {
return;
}
mScreenDecorHwcLayer.updateRoundedCornerDrawable(topDrawable, bottomDrawable);
diff --git a/packages/SystemUI/src/com/android/systemui/decor/DebugRoundedCornerDelegate.kt b/packages/SystemUI/src/com/android/systemui/decor/DebugRoundedCornerDelegate.kt
index 4069bc7d73d0..557168731b23 100644
--- a/packages/SystemUI/src/com/android/systemui/decor/DebugRoundedCornerDelegate.kt
+++ b/packages/SystemUI/src/com/android/systemui/decor/DebugRoundedCornerDelegate.kt
@@ -77,16 +77,30 @@ class DebugRoundedCornerDelegate : RoundedCornerResDelegate {
}
fun applyNewDebugCorners(
- topCorner: DebugRoundedCornerModel,
- bottomCorner: DebugRoundedCornerModel,
+ topCorner: DebugRoundedCornerModel?,
+ bottomCorner: DebugRoundedCornerModel?,
) {
- hasTop = true
- topRoundedDrawable = topCorner.toPathDrawable(paint)
- topRoundedSize = topCorner.size()
+ topCorner?.let {
+ hasTop = true
+ topRoundedDrawable = it.toPathDrawable(paint)
+ topRoundedSize = it.size()
+ }
+ ?: {
+ hasTop = false
+ topRoundedDrawable = null
+ topRoundedSize = Size(0, 0)
+ }
- hasBottom = true
- bottomRoundedDrawable = bottomCorner.toPathDrawable(paint)
- bottomRoundedSize = bottomCorner.size()
+ bottomCorner?.let {
+ hasBottom = true
+ bottomRoundedDrawable = it.toPathDrawable(paint)
+ bottomRoundedSize = it.size()
+ }
+ ?: {
+ hasBottom = false
+ bottomRoundedDrawable = null
+ bottomRoundedSize = Size(0, 0)
+ }
}
/**
diff --git a/packages/SystemUI/src/com/android/systemui/decor/ScreenDecorCommand.kt b/packages/SystemUI/src/com/android/systemui/decor/ScreenDecorCommand.kt
new file mode 100644
index 000000000000..fa1d898de850
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/decor/ScreenDecorCommand.kt
@@ -0,0 +1,171 @@
+/*
+ * 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.systemui.decor
+
+import android.graphics.Color
+import android.graphics.Path
+import android.util.PathParser
+import com.android.systemui.statusbar.commandline.ParseableCommand
+import com.android.systemui.statusbar.commandline.Type
+import com.android.systemui.statusbar.commandline.map
+import java.io.PrintWriter
+
+/** Debug screen-decor command to be handled by the SystemUI command line interface */
+class ScreenDecorCommand(
+ private val callback: Callback,
+) : ParseableCommand(SCREEN_DECOR_CMD_NAME) {
+ val debug: Boolean? by
+ param(
+ longName = "debug",
+ description =
+ "Enter or exits debug mode. Effectively makes the corners visible and allows " +
+ "for overriding the path data for the anti-aliasing corner paths and display " +
+ "cutout.",
+ valueParser = Type.Boolean,
+ )
+
+ val color: Int? by
+ param(
+ longName = "color",
+ shortName = "c",
+ description =
+ "Set a specific color for the debug assets. See Color#parseString() for " +
+ "accepted inputs.",
+ valueParser = Type.String.map { it.toColorIntOrNull() }
+ )
+
+ val roundedTop: RoundedCornerSubCommand? by subCommand(RoundedCornerSubCommand("rounded-top"))
+
+ val roundedBottom: RoundedCornerSubCommand? by
+ subCommand(RoundedCornerSubCommand("rounded-bottom"))
+
+ override fun execute(pw: PrintWriter) {
+ callback.onExecute(this, pw)
+ }
+
+ override fun toString(): String {
+ return "ScreenDecorCommand(" +
+ "debug=$debug, " +
+ "color=$color, " +
+ "roundedTop=$roundedTop, " +
+ "roundedBottom=$roundedBottom)"
+ }
+
+ /** For use in ScreenDecorations.java, define a Callback */
+ interface Callback {
+ fun onExecute(cmd: ScreenDecorCommand, pw: PrintWriter)
+ }
+
+ companion object {
+ const val SCREEN_DECOR_CMD_NAME = "screen-decor"
+ }
+}
+
+/**
+ * Defines a subcommand suitable for `rounded-top` and `rounded-bottom`. They both have the same
+ * API.
+ */
+class RoundedCornerSubCommand(name: String) : ParseableCommand(name) {
+ val height by
+ param(
+ longName = "height",
+ description = "The height of a corner, in pixels.",
+ valueParser = Type.Int,
+ )
+ .required()
+
+ val width by
+ param(
+ longName = "width",
+ description =
+ "The width of the corner, in pixels. Likely should be equal to the height.",
+ valueParser = Type.Int,
+ )
+ .required()
+
+ val pathData by
+ param(
+ longName = "path-data",
+ shortName = "d",
+ description =
+ "PathParser-compatible path string to be rendered as the corner drawable. " +
+ "This path should be a closed arc oriented as the top-left corner " +
+ "of the device",
+ valueParser = Type.String.map { it.toPathOrNull() }
+ )
+ .required()
+
+ val viewportHeight: Float? by
+ param(
+ longName = "viewport-height",
+ description =
+ "The height of the viewport for the given path string. " +
+ "If null, the corner height will be used.",
+ valueParser = Type.Float,
+ )
+
+ val scaleY: Float
+ get() = viewportHeight?.let { height.toFloat() / it } ?: 1.0f
+
+ val viewportWidth: Float? by
+ param(
+ longName = "viewport-width",
+ description =
+ "The width of the viewport for the given path string. " +
+ "If null, the corner width will be used.",
+ valueParser = Type.Float,
+ )
+
+ val scaleX: Float
+ get() = viewportWidth?.let { width.toFloat() / it } ?: 1.0f
+
+ override fun execute(pw: PrintWriter) {
+ // Not needed for a subcommand
+ }
+
+ override fun toString(): String {
+ return "RoundedCornerSubCommand(" +
+ "height=$height," +
+ " width=$width," +
+ " pathData='$pathData'," +
+ " viewportHeight=$viewportHeight," +
+ " viewportWidth=$viewportWidth)"
+ }
+
+ fun toRoundedCornerDebugModel(): DebugRoundedCornerModel =
+ DebugRoundedCornerModel(
+ path = pathData,
+ width = width,
+ height = height,
+ scaleX = scaleX,
+ scaleY = scaleY,
+ )
+}
+
+fun String.toPathOrNull(): Path? =
+ try {
+ PathParser.createPathFromPathData(this)
+ } catch (e: Exception) {
+ null
+ }
+
+fun String.toColorIntOrNull(): Int? =
+ try {
+ Color.parseColor(this)
+ } catch (e: Exception) {
+ null
+ }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt
new file mode 100644
index 000000000000..de369c35345c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt
@@ -0,0 +1,327 @@
+/*
+ * 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.systemui.statusbar.commandline
+
+/**
+ * [CommandParser] defines the collection of tokens which can be parsed from an incoming command
+ * list, and parses them into their respective containers. Supported tokens are of the following
+ * forms:
+ * ```
+ * Flag: boolean value, false by default. always optional.
+ * Param: named parameter, taking N args all of a given type. Currently only single arg parameters
+ * are supported.
+ * SubCommand: named command created by adding a command to a parent. Supports all fields above, but
+ * not other subcommands.
+ * ```
+ *
+ * Tokens are added via the factory methods for each token type. They can be made `required` by
+ * calling the [require] method for the appropriate type, as follows:
+ * ```
+ * val requiredParam = parser.require(parser.param(...))
+ * ```
+ *
+ * The reason for having an explicit require is so that generic type arguments can be handled
+ * properly. See [SingleArgParam] and [SingleArgParamOptional] for the difference between an
+ * optional parameter and a required one.
+ *
+ * Typical usage of a required parameter, however, will occur within the context of a
+ * [ParseableCommand], which defines a convenience `require()` method:
+ * ```
+ * class MyCommand : ParseableCommand {
+ * val requiredParam = param(...).require()
+ * }
+ * ```
+ *
+ * This parser defines two modes of parsing, both of which validate for required parameters.
+ * 1. [parse] is a top-level parsing method. This parser will walk the given arg list and populate
+ * all of the delegate classes based on their type. It will handle SubCommands, and after parsing
+ * will check for any required-but-missing SubCommands or Params.
+ *
+ * **This method requires that every received token is represented in its grammar.**
+ * 2. [parseAsSubCommand] is a second-level parsing method suitable for any [SubCommand]. This
+ * method will handle _only_ flags and params. It will return parsing control to its parent
+ * parser on the first unknown token rather than throwing.
+ */
+class CommandParser {
+ private val _flags = mutableListOf<Flag>()
+ val flags: List<Flag> = _flags
+ private val _params = mutableListOf<Param>()
+ val params: List<Param> = _params
+ private val _subCommands = mutableListOf<SubCommand>()
+ val subCommands: List<SubCommand> = _subCommands
+
+ private val tokenSet = mutableSetOf<String>()
+
+ /**
+ * Parse the arg list into the fields defined in the containing class.
+ *
+ * @return true if all required fields are present after parsing
+ * @throws ArgParseError on any failure to process args
+ */
+ fun parse(args: List<String>): Boolean {
+ if (args.isEmpty()) {
+ return false
+ }
+
+ val iterator = args.listIterator()
+ var tokenHandled: Boolean
+ while (iterator.hasNext()) {
+ val token = iterator.next()
+ tokenHandled = false
+
+ flags
+ .find { it.matches(token) }
+ ?.let {
+ it.inner = true
+ tokenHandled = true
+ }
+
+ if (tokenHandled) continue
+
+ params
+ .find { it.matches(token) }
+ ?.let {
+ it.parseArgsFromIter(iterator)
+ tokenHandled = true
+ }
+
+ if (tokenHandled) continue
+
+ subCommands
+ .find { it.matches(token) }
+ ?.let {
+ it.parseSubCommandArgs(iterator)
+ tokenHandled = true
+ }
+
+ if (!tokenHandled) {
+ throw ArgParseError("Unknown token: $token")
+ }
+ }
+
+ return validateRequiredParams()
+ }
+
+ /**
+ * Parse a subset of the commands that came in from the top-level [parse] method, for the
+ * subcommand that this parser represents. Note that subcommands may not contain other
+ * subcommands. But they may contain flags and params.
+ *
+ * @return true if all required fields are present after parsing
+ * @throws ArgParseError on any failure to process args
+ */
+ fun parseAsSubCommand(iter: ListIterator<String>): Boolean {
+ // arg[-1] is our subcommand name, so the rest of the args are either for this
+ // subcommand, OR for the top-level command to handle. Therefore, we bail on the first
+ // failure, but still check our own required params
+
+ // The mere presence of a subcommand (similar to a flag) is a valid subcommand
+ if (flags.isEmpty() && params.isEmpty()) {
+ return validateRequiredParams()
+ }
+
+ var tokenHandled: Boolean
+ while (iter.hasNext()) {
+ val token = iter.next()
+ tokenHandled = false
+
+ flags
+ .find { it.matches(token) }
+ ?.let {
+ it.inner = true
+ tokenHandled = true
+ }
+
+ if (tokenHandled) continue
+
+ params
+ .find { it.matches(token) }
+ ?.let {
+ it.parseArgsFromIter(iter)
+ tokenHandled = true
+ }
+
+ if (!tokenHandled) {
+ // Move the cursor position backwards since we've arrived at a token
+ // that we don't own
+ iter.previous()
+ break
+ }
+ }
+
+ return validateRequiredParams()
+ }
+
+ /**
+ * If [parse] or [parseAsSubCommand] does not produce a valid result, generate a list of errors
+ * based on missing elements
+ */
+ fun generateValidationErrorMessages(): List<String> {
+ val missingElements = mutableListOf<String>()
+
+ if (unhandledParams.isNotEmpty()) {
+ val names = unhandledParams.map { it.longName }
+ missingElements.add("No values passed for required params: $names")
+ }
+
+ if (unhandledSubCmds.isNotEmpty()) {
+ missingElements.addAll(unhandledSubCmds.map { it.longName })
+ val names = unhandledSubCmds.map { it.shortName }
+ missingElements.add("No values passed for required sub-commands: $names")
+ }
+
+ return missingElements
+ }
+
+ /** Check for any missing, required params, or any invalid subcommands */
+ private fun validateRequiredParams(): Boolean =
+ unhandledParams.isEmpty() && unhandledSubCmds.isEmpty() && unvalidatedSubCmds.isEmpty()
+
+ // If any required param (aka non-optional) hasn't handled a field, then return false
+ private val unhandledParams: List<Param>
+ get() = params.filter { (it is SingleArgParam<*>) && !it.handled }
+
+ private val unhandledSubCmds: List<SubCommand>
+ get() = subCommands.filter { (it is RequiredSubCommand<*> && !it.handled) }
+
+ private val unvalidatedSubCmds: List<SubCommand>
+ get() = subCommands.filter { !it.validationStatus }
+
+ private fun checkCliNames(short: String?, long: String): String? {
+ if (short != null && tokenSet.contains(short)) {
+ return short
+ }
+
+ if (tokenSet.contains(long)) {
+ return long
+ }
+
+ return null
+ }
+
+ private fun subCommandContainsSubCommands(cmd: ParseableCommand): Boolean =
+ cmd.parser.subCommands.isNotEmpty()
+
+ private fun registerNames(short: String?, long: String) {
+ if (short != null) {
+ tokenSet.add(short)
+ }
+ tokenSet.add(long)
+ }
+
+ /**
+ * Turns a [SingleArgParamOptional]<T> into a [SingleArgParam] by converting the [T?] into [T]
+ *
+ * @return a [SingleArgParam] property delegate
+ */
+ fun <T : Any> require(old: SingleArgParamOptional<T>): SingleArgParam<T> {
+ val newParam =
+ SingleArgParam(
+ longName = old.longName,
+ shortName = old.shortName,
+ description = old.description,
+ valueParser = old.valueParser,
+ )
+
+ replaceWithRequired(old, newParam)
+ return newParam
+ }
+
+ private fun <T : Any> replaceWithRequired(
+ old: SingleArgParamOptional<T>,
+ new: SingleArgParam<T>,
+ ) {
+ _params.remove(old)
+ _params.add(new)
+ }
+
+ /**
+ * Turns an [OptionalSubCommand] into a [RequiredSubCommand] by converting the [T?] in to [T]
+ *
+ * @return a [RequiredSubCommand] property delegate
+ */
+ fun <T : ParseableCommand> require(optional: OptionalSubCommand<T>): RequiredSubCommand<T> {
+ val newCmd = RequiredSubCommand(optional.cmd)
+ replaceWithRequired(optional, newCmd)
+ return newCmd
+ }
+
+ private fun <T : ParseableCommand> replaceWithRequired(
+ old: OptionalSubCommand<T>,
+ new: RequiredSubCommand<T>,
+ ) {
+ _subCommands.remove(old)
+ _subCommands.add(new)
+ }
+
+ internal fun flag(
+ longName: String,
+ shortName: String? = null,
+ description: String = "",
+ ): Flag {
+ checkCliNames(shortName, longName)?.let {
+ throw IllegalArgumentException("Detected reused flag name ($it)")
+ }
+ registerNames(shortName, longName)
+
+ val flag = Flag(shortName, longName, description)
+ _flags.add(flag)
+ return flag
+ }
+
+ internal fun <T : Any> param(
+ longName: String,
+ shortName: String? = null,
+ description: String = "",
+ valueParser: ValueParser<T>,
+ ): SingleArgParamOptional<T> {
+ checkCliNames(shortName, longName)?.let {
+ throw IllegalArgumentException("Detected reused param name ($it)")
+ }
+ registerNames(shortName, longName)
+
+ val param =
+ SingleArgParamOptional(
+ shortName = shortName,
+ longName = longName,
+ description = description,
+ valueParser = valueParser,
+ )
+ _params.add(param)
+ return param
+ }
+
+ internal fun <T : ParseableCommand> subCommand(
+ command: T,
+ ): OptionalSubCommand<T> {
+ checkCliNames(null, command.name)?.let {
+ throw IllegalArgumentException("Cannot re-use name for subcommand ($it)")
+ }
+
+ if (subCommandContainsSubCommands(command)) {
+ throw IllegalArgumentException(
+ "SubCommands may not contain other SubCommands. $command"
+ )
+ }
+
+ registerNames(null, command.name)
+
+ val subCmd = OptionalSubCommand(command)
+ _subCommands.add(subCmd)
+ return subCmd
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt
new file mode 100644
index 000000000000..6ed5eed79c82
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt
@@ -0,0 +1,195 @@
+/*
+ * 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.systemui.statusbar.commandline
+
+import android.util.IndentingPrintWriter
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+/**
+ * Definitions for all parameter types usable by [ParseableCommand]. Parameters are command line
+ * tokens that accept a fixed number of arguments and convert them to a parsed type.
+ *
+ * Example:
+ * ```
+ * my_command --single-arg-param arg
+ * ```
+ *
+ * In the example, `my_command` is the name of the command, `--single-arg-param` is the parameter,
+ * and `arg` is the value parsed by that parameter into its eventual type.
+ *
+ * Note on generics: The intended usage for parameters is to be able to return the parsed type from
+ * the given command as a `val` via property delegation. For example, let's say we have a command
+ * that has one optional and one required parameter:
+ * ```
+ * class MyCommand : ParseableCommand {
+ * val requiredParam: Int by parser.param(...).required()
+ * val optionalParam: Int? by parser.param(...)
+ * }
+ * ```
+ *
+ * In order to make the simple `param` method return the correct type, we need to do two things:
+ * 1. Break out the generic type into 2 pieces (TParsed and T)
+ * 2. Create two different underlying Parameter subclasses to handle the property delegation. One
+ * handles `T?` and the other handles `T`. Note that in both cases, `TParsed` is always non-null
+ * since the value parsed from the argument will throw an exception if missing or if it cannot be
+ * parsed.
+ */
+
+/** A param type knows the number of arguments it expects */
+sealed interface Param : Describable {
+ val numArgs: Int
+
+ /**
+ * Consume [numArgs] items from the iterator and relay the result into its corresponding
+ * delegated type.
+ */
+ fun parseArgsFromIter(iterator: Iterator<String>)
+}
+
+/**
+ * Base class for required and optional SingleArgParam classes. For convenience, UnaryParam is
+ * defined as a [MultipleArgParam] where numArgs = 1. The benefit is that we can define the parsing
+ * in a single place, and yet on the client side we can unwrap the underlying list of params
+ * automatically.
+ */
+abstract class UnaryParamBase<out T, out TParsed : T>(val wrapped: MultipleArgParam<T, TParsed>) :
+ Param, ReadOnlyProperty<Any?, T> {
+ var handled = false
+
+ override fun describe(pw: IndentingPrintWriter) {
+ if (shortName != null) {
+ pw.print("$shortName, ")
+ }
+ pw.print(longName)
+ pw.println(" ${typeDescription()}")
+ if (description != null) {
+ pw.indented { pw.println(description) }
+ }
+ }
+
+ /**
+ * Try to describe the arg type. We can know if it's one of the base types what kind of input it
+ * takes. Otherwise just print "<arg>" and let the clients describe in the help text
+ */
+ private fun typeDescription() =
+ when (wrapped.valueParser) {
+ Type.Int -> "<int>"
+ Type.Float -> "<float>"
+ Type.String -> "<string>"
+ Type.Boolean -> "<boolean>"
+ else -> "<arg>"
+ }
+}
+
+/** Required single-arg parameter, delegating a non-null type to the client. */
+class SingleArgParam<out T : Any>(
+ override val longName: String,
+ override val shortName: String? = null,
+ override val description: String? = null,
+ val valueParser: ValueParser<T>,
+) :
+ UnaryParamBase<T, T>(
+ MultipleArgParam(
+ longName,
+ shortName,
+ 1,
+ description,
+ valueParser,
+ )
+ ) {
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T =
+ if (handled) {
+ wrapped.getValue(thisRef, property)[0]
+ } else {
+ throw IllegalStateException("Attempt to read property before parse() has executed")
+ }
+
+ override val numArgs: Int = 1
+
+ override fun parseArgsFromIter(iterator: Iterator<String>) {
+ wrapped.parseArgsFromIter(iterator)
+ handled = true
+ }
+}
+
+/** Optional single-argument parameter, delegating a nullable type to the client. */
+class SingleArgParamOptional<out T : Any>(
+ override val longName: String,
+ override val shortName: String? = null,
+ override val description: String? = null,
+ val valueParser: ValueParser<T>,
+) :
+ UnaryParamBase<T?, T>(
+ MultipleArgParam(
+ longName,
+ shortName,
+ 1,
+ description,
+ valueParser,
+ )
+ ) {
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T? =
+ wrapped.getValue(thisRef, property).getOrNull(0)
+
+ override val numArgs: Int = 1
+
+ override fun parseArgsFromIter(iterator: Iterator<String>) {
+ wrapped.parseArgsFromIter(iterator)
+ handled = true
+ }
+}
+
+/**
+ * Parses a list of args into the underlying [T] data type. The resultant value is an ordered list
+ * of type [TParsed].
+ *
+ * [T] and [TParsed] are split out here in the case where the entire param is optional. I.e., a
+ * MultipleArgParam<T?, T> indicates a command line argument that can be omitted. In that case, the
+ * inner list is List<T>?, NOT List<T?>. If the argument is provided, then the type is always going
+ * to be parsed into T rather than T?.
+ */
+class MultipleArgParam<out T, out TParsed : T>(
+ override val longName: String,
+ override val shortName: String? = null,
+ override val numArgs: Int = 1,
+ override val description: String? = null,
+ val valueParser: ValueParser<TParsed>,
+) : ReadOnlyProperty<Any?, List<TParsed>>, Param {
+ private val inner: MutableList<TParsed> = mutableListOf()
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>): List<TParsed> = inner
+
+ /**
+ * Consumes [numArgs] values of the iterator and parses them into [TParsed].
+ *
+ * @throws ArgParseError on the first failure
+ */
+ override fun parseArgsFromIter(iterator: Iterator<String>) {
+ if (!iterator.hasNext()) {
+ throw ArgParseError("no argument provided for $shortName")
+ }
+ for (i in 0 until numArgs) {
+ valueParser
+ .parseValue(iterator.next())
+ .fold(onSuccess = { inner.add(it) }, onFailure = { throw it })
+ }
+ }
+}
+
+data class ArgParseError(override val message: String) : Exception(message)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt
new file mode 100644
index 000000000000..ecd3fa6cc299
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt
@@ -0,0 +1,395 @@
+/*
+ * 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.systemui.statusbar.commandline
+
+import android.util.IndentingPrintWriter
+import java.io.PrintWriter
+import java.lang.IllegalArgumentException
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+/**
+ * An implementation of [Command] that includes a [CommandParser] which can set all delegated
+ * properties.
+ *
+ * As the number of registrants to [CommandRegistry] grows, we should have a default mechanism for
+ * parsing common command line arguments. We are not expecting to build an arbitrarily-functional
+ * CLI, nor a GNU arg parse compliant interface here, we simply want to be able to empower clients
+ * to create simple CLI grammars such as:
+ * ```
+ * $ my_command [-f|--flag]
+ * $ my_command [-a|--arg] <params...>
+ * $ my_command [subcommand1] [subcommand2]
+ * $ my_command <positional_arg ...> # not-yet implemented
+ * ```
+ *
+ * Note that the flags `-h` and `--help` are reserved for the base class. It seems prudent to just
+ * avoid them in your implementation.
+ *
+ * Usage:
+ *
+ * The intended usage tries to be clever enough to enable good ergonomics, while not too clever as
+ * to be unmaintainable. Using the default parser is done using property delegates, and looks like:
+ * ```
+ * class MyCommand(
+ * onExecute: (cmd: MyCommand, pw: PrintWriter) -> ()
+ * ) : ParseableCommand(name) {
+ * val flag1 by flag(
+ * shortName = "-f",
+ * longName = "--flag",
+ * required = false,
+ * )
+ * val param1: String by param(
+ * shortName = "-a",
+ * longName = "--args",
+ * valueParser = Type.String
+ * ).required()
+ * val param2: Int by param(..., valueParser = Type.Int)
+ * val subCommand by subCommand(...)
+ *
+ * override fun execute(pw: PrintWriter) {
+ * onExecute(this, pw)
+ * }
+ *
+ * companion object {
+ * const val name = "my_command"
+ * }
+ * }
+ *
+ * fun main() {
+ * fun printArgs(cmd: MyCommand, pw: PrintWriter) {
+ * pw.println("${cmd.flag1}")
+ * pw.println("${cmd.param1}")
+ * pw.println("${cmd.param2}")
+ * pw.println("${cmd.subCommand}")
+ * }
+ *
+ * commandRegistry.registerCommand(MyCommand.companion.name) {
+ * MyCommand() { (cmd, pw) ->
+ * printArgs(cmd, pw)
+ * }
+ * }
+ * }
+ *
+ * ```
+ */
+abstract class ParseableCommand(val name: String, val description: String? = null) : Command {
+ val parser: CommandParser = CommandParser()
+
+ val help by flag(longName = "help", shortName = "h", description = "Print help and return")
+
+ /**
+ * After [execute(pw, args)] is called, this class goes through a parsing stage and sets all
+ * delegated properties. It is safe to read any delegated properties here.
+ *
+ * This method is never called for [SubCommand]s, since they are associated with a top-level
+ * command that handles [execute]
+ */
+ abstract fun execute(pw: PrintWriter)
+
+ /**
+ * Given a command string list, [execute] parses the incoming command and validates the input.
+ * If this command or any of its subcommands is passed `-h` or `--help`, then execute will only
+ * print the relevant help message and exit.
+ *
+ * If any error is thrown during parsing, we will catch and log the error. This process should
+ * _never_ take down its process. Override [onParseFailed] to handle an [ArgParseError].
+ *
+ * Important: none of the delegated fields can be read before this stage.
+ */
+ override fun execute(pw: PrintWriter, args: List<String>) {
+ val success: Boolean
+ try {
+ success = parser.parse(args)
+ } catch (e: ArgParseError) {
+ pw.println(e.message)
+ onParseFailed(e)
+ return
+ } catch (e: Exception) {
+ pw.println("Unknown exception encountered during parse")
+ pw.println(e)
+ return
+ }
+
+ // Now we've parsed the incoming command without error. There are two things to check:
+ // 1. If any help is requested, print the help message and return
+ // 2. Otherwise, make sure required params have been passed in, and execute
+
+ val helpSubCmds = subCmdsRequestingHelp()
+
+ // Top-level help encapsulates subcommands. Otherwise, if _any_ subcommand requests
+ // help then defer to them. Else, just execute
+ if (help) {
+ help(pw)
+ } else if (helpSubCmds.isNotEmpty()) {
+ helpSubCmds.forEach { it.help(pw) }
+ } else {
+ if (!success) {
+ parser.generateValidationErrorMessages().forEach { pw.println(it) }
+ } else {
+ execute(pw)
+ }
+ }
+ }
+
+ /**
+ * Returns a list of all commands that asked for help. If non-empty, parsing will stop to print
+ * help. It is not guaranteed that delegates are fulfilled if help is requested
+ */
+ private fun subCmdsRequestingHelp(): List<ParseableCommand> =
+ parser.subCommands.filter { it.cmd.help }.map { it.cmd }
+
+ /** Override to do something when parsing fails */
+ open fun onParseFailed(error: ArgParseError) {}
+
+ /** Override to print a usage clause. E.g. `usage: my-cmd <arg1> <arg2>` */
+ open fun usage(pw: IndentingPrintWriter) {}
+
+ /**
+ * Print out the list of tokens, their received types if any, and their description in a
+ * formatted string.
+ *
+ * Example:
+ * ```
+ * my-command:
+ * MyCmd.description
+ *
+ * [optional] usage block
+ *
+ * Flags:
+ * -f
+ * description
+ * --flag2
+ * description
+ *
+ * Parameters:
+ * Required:
+ * -p1 [Param.Type]
+ * description
+ * --param2 [Param.Type]
+ * description
+ * Optional:
+ * same as above
+ *
+ * SubCommands:
+ * Required:
+ * ...
+ * Optional:
+ * ...
+ * ```
+ */
+ override fun help(pw: PrintWriter) {
+ val ipw = IndentingPrintWriter(pw)
+ ipw.printBoxed(name)
+ ipw.println()
+
+ // Allow for a simple `usage` block for clients
+ ipw.indented { usage(ipw) }
+
+ if (description != null) {
+ ipw.indented { ipw.println(description) }
+ ipw.println()
+ }
+
+ val flags = parser.flags
+ if (flags.isNotEmpty()) {
+ ipw.println("FLAGS:")
+ ipw.indented {
+ flags.forEach {
+ it.describe(ipw)
+ ipw.println()
+ }
+ }
+ }
+
+ val (required, optional) = parser.params.partition { it is SingleArgParam<*> }
+ if (required.isNotEmpty()) {
+ ipw.println("REQUIRED PARAMS:")
+ required.describe(ipw)
+ }
+ if (optional.isNotEmpty()) {
+ ipw.println("OPTIONAL PARAMS:")
+ optional.describe(ipw)
+ }
+
+ val (reqSub, optSub) = parser.subCommands.partition { it is RequiredSubCommand<*> }
+ if (reqSub.isNotEmpty()) {
+ ipw.println("REQUIRED SUBCOMMANDS:")
+ reqSub.describe(ipw)
+ }
+ if (optSub.isNotEmpty()) {
+ ipw.println("OPTIONAL SUBCOMMANDS:")
+ optSub.describe(ipw)
+ }
+ }
+
+ fun flag(
+ longName: String,
+ shortName: String? = null,
+ description: String = "",
+ ): Flag {
+ if (!checkShortName(shortName)) {
+ throw IllegalArgumentException(
+ "Flag short name must be one character long, or null. Got ($shortName)"
+ )
+ }
+
+ if (!checkLongName(longName)) {
+ throw IllegalArgumentException("Flags must not start with '-'. Got $($longName)")
+ }
+
+ val short = shortName?.let { "-$shortName" }
+ val long = "--$longName"
+
+ return parser.flag(long, short, description)
+ }
+
+ fun <T : Any> param(
+ longName: String,
+ shortName: String? = null,
+ description: String = "",
+ valueParser: ValueParser<T>,
+ ): SingleArgParamOptional<T> {
+ if (!checkShortName(shortName)) {
+ throw IllegalArgumentException(
+ "Parameter short name must be one character long, or null. Got ($shortName)"
+ )
+ }
+
+ if (!checkLongName(longName)) {
+ throw IllegalArgumentException("Parameters must not start with '-'. Got $($longName)")
+ }
+
+ val short = shortName?.let { "-$shortName" }
+ val long = "--$longName"
+
+ return parser.param(long, short, description, valueParser)
+ }
+
+ fun <T : ParseableCommand> subCommand(
+ command: T,
+ ) = parser.subCommand(command)
+
+ /** For use in conjunction with [param], makes the parameter required */
+ fun <T : Any> SingleArgParamOptional<T>.required(): SingleArgParam<T> = parser.require(this)
+
+ /** For use in conjunction with [subCommand], makes the given [SubCommand] required */
+ fun <T : ParseableCommand> OptionalSubCommand<T>.required(): RequiredSubCommand<T> =
+ parser.require(this)
+
+ private fun checkShortName(short: String?): Boolean {
+ return short == null || short.length == 1
+ }
+
+ private fun checkLongName(long: String): Boolean {
+ return !long.startsWith("-")
+ }
+
+ companion object {
+ fun Iterable<Describable>.describe(pw: IndentingPrintWriter) {
+ pw.indented {
+ forEach {
+ it.describe(pw)
+ pw.println()
+ }
+ }
+ }
+ }
+}
+
+/**
+ * A flag is a boolean value passed over the command line. It can have a short form or long form.
+ * The value is [Boolean.true] if the flag is found, else false
+ */
+data class Flag(
+ override val shortName: String? = null,
+ override val longName: String,
+ override val description: String? = null,
+) : ReadOnlyProperty<Any?, Boolean>, Describable {
+ var inner: Boolean = false
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>) = inner
+}
+
+/**
+ * Named CLI token. Can have a short or long name. Note: consider renaming to "primary" and
+ * "secondary" names since we don't actually care what the strings are
+ *
+ * Flags and params will have [shortName]s that are always prefixed with a single dash, while
+ * [longName]s are prefixed by a double dash. E.g., `my_command -f --flag`.
+ *
+ * Subcommands do not do any prefixing, and register their name as the [longName]
+ *
+ * Can be matched against an incoming token
+ */
+interface CliNamed {
+ val shortName: String?
+ val longName: String
+
+ fun matches(token: String) = shortName == token || longName == token
+}
+
+interface Describable : CliNamed {
+ val description: String?
+
+ fun describe(pw: IndentingPrintWriter) {
+ if (shortName != null) {
+ pw.print("$shortName, ")
+ }
+ pw.print(longName)
+ pw.println()
+ if (description != null) {
+ pw.indented { pw.println(description) }
+ }
+ }
+}
+
+/**
+ * Print [s] inside of a unicode character box, like so:
+ * ```
+ * ╔═══════════╗
+ * ║ my-string ║
+ * ╚═══════════╝
+ * ```
+ */
+fun PrintWriter.printDoubleBoxed(s: String) {
+ val length = s.length
+ println("╔${"═".repeat(length + 2)}╗")
+ println("║ $s ║")
+ println("╚${"═".repeat(length + 2)}╝")
+}
+
+/**
+ * Print [s] inside of a unicode character box, like so:
+ * ```
+ * ┌───────────┐
+ * │ my-string │
+ * └───────────┘
+ * ```
+ */
+fun PrintWriter.printBoxed(s: String) {
+ val length = s.length
+ println("┌${"─".repeat(length + 2)}┐")
+ println("│ $s │")
+ println("└${"─".repeat(length + 2)}┘")
+}
+
+fun IndentingPrintWriter.indented(block: () -> Unit) {
+ increaseIndent()
+ block()
+ decreaseIndent()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt
new file mode 100644
index 000000000000..41bac86fd6c9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.systemui.statusbar.commandline
+
+import android.util.IndentingPrintWriter
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+/**
+ * Sub commands wrap [ParseableCommand]s and are attached to a parent [ParseableCommand]. As such
+ * they have their own parser which will parse the args as a subcommand. I.e., the subcommand's
+ * parser will consume the iterator created by the parent, reversing the index when it reaches an
+ * unknown token.
+ *
+ * In order to keep subcommands relatively simple and not have to do complicated validation, sub
+ * commands will return control to the parent parser as soon as they discover a token that they do
+ * not own. They will throw an [ArgParseError] if parsing fails or if they don't receive arguments
+ * for a required parameter.
+ */
+sealed interface SubCommand : Describable {
+ val cmd: ParseableCommand
+
+ /** Checks if all of the required elements were passed in to [parseSubCommandArgs] */
+ var validationStatus: Boolean
+
+ /**
+ * To keep parsing simple, [parseSubCommandArgs] requires a [ListIterator] so that it can rewind
+ * the iterator when it yields control upwards
+ */
+ fun parseSubCommandArgs(iterator: ListIterator<String>)
+}
+
+/**
+ * Note that the delegated type from the subcommand is `T: ParseableCommand?`. SubCommands are
+ * created via adding a fully-formed [ParseableCommand] to parent command.
+ *
+ * At this point in time, I don't recommend nesting subcommands.
+ */
+class OptionalSubCommand<T : ParseableCommand>(
+ override val cmd: T,
+) : SubCommand, ReadOnlyProperty<Any?, ParseableCommand?> {
+ override val shortName: String? = null
+ override val longName: String = cmd.name
+ override val description: String? = cmd.description
+ override var validationStatus = true
+
+ private var isPresent = false
+
+ /** Consume tokens from the iterator and pass them to the wrapped command */
+ override fun parseSubCommandArgs(iterator: ListIterator<String>) {
+ validationStatus = cmd.parser.parseAsSubCommand(iterator)
+ isPresent = true
+ }
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T? =
+ if (isPresent) {
+ cmd
+ } else {
+ null
+ }
+
+ override fun describe(pw: IndentingPrintWriter) {
+ cmd.help(pw)
+ }
+}
+
+/**
+ * Non-optional subcommand impl. Top-level parser is expected to throw [ArgParseError] if this token
+ * is not present in the incoming command
+ */
+class RequiredSubCommand<T : ParseableCommand>(
+ override val cmd: T,
+) : SubCommand, ReadOnlyProperty<Any?, ParseableCommand> {
+ override val shortName: String? = null
+ override val longName: String = cmd.name
+ override val description: String? = cmd.description
+ override var validationStatus = true
+
+ /** Unhandled, required subcommands are an error */
+ var handled = false
+
+ override fun parseSubCommandArgs(iterator: ListIterator<String>) {
+ validationStatus = cmd.parser.parseAsSubCommand(iterator)
+ handled = true
+ }
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>): ParseableCommand = cmd
+
+ override fun describe(pw: IndentingPrintWriter) {
+ cmd.help(pw)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt
new file mode 100644
index 000000000000..01083d9a7907
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt
@@ -0,0 +1,173 @@
+/*
+ * 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.systemui.statusbar.commandline
+
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * Utilities for parsing the [String] command line arguments. Arguments are related to the
+ * [Parameter] type, which declares the number of, and resulting type of, the arguments that it
+ * takes when parsing. For Example:
+ * ```
+ * my-command --param <str> --param2 <int>
+ * ```
+ *
+ * Defines 2 parameters, the first of which takes a string, and the second requires an int. Because
+ * fundamentally _everything_ is a string, we have to define a convenient way to get from the
+ * incoming `StringArg` to the resulting `T`-arg, where `T` is the type required by the client.
+ *
+ * Parsing is therefore a relatively straightforward operation: (String) -> T. However, since
+ * parsing can always fail, the type is actually (String) -> Result<T>. We will always want to fail
+ * on the first error and propagate it to the caller (typically this results in printing the `help`
+ * message of the command`).
+ *
+ * The identity parsing is trivial:
+ * ```
+ * (s: String) -> String = { s -> s }
+ * ```
+ *
+ * Basic mappings are actually even provided by Kotlin's stdlib:
+ * ```
+ * (s: String) -> Boolean = { s -> s.toBooleanOrNull() }
+ * (s: String) -> Int = { s -> s.toIntOrNull() }
+ * ...
+ * ```
+ *
+ * In order to properly encode errors, we will ascribe an error type to any `null` values, such that
+ * parsing looks like this:
+ * ```
+ * val mapping: (String) -> T? = {...} // for some T
+ * val parser: (String) -> Result<T> = { s ->
+ * mapping(s)?.let {
+ * Result.success(it)
+ * } ?: Result.failure(/* some failure type */)
+ * }
+ * ```
+ *
+ * Composition
+ *
+ * The ability to compose value parsing enables us to provide a couple of reasonable default parsers
+ * and allow clients to seamlessly build upon that using map functions. Consider the case where we
+ * want to validate that a value is an [Int] between 0 and 100. We start with the generic [Int]
+ * parser, and a validator, of the type (Int) -> Result<Int>:
+ * ```
+ * val intParser = { s ->
+ * s.toStringOrNull().?let {...} ?: ...
+ * }
+ *
+ * val validator = { i ->
+ * if (i > 100 || i < 0) {
+ * Result.failure(...)
+ * } else {
+ * Result.success(i)
+ * }
+ * ```
+ *
+ * In order to combine these functions, we need to define a new [flatMap] function that can get us
+ * from a `Result<T>` to a `Result<R>`, and short-circuit on any error. We want to see this:
+ * ```
+ * val validatingParser = { s ->
+ * intParser.invoke(s).flatMap { i ->
+ * validator(i)
+ * }
+ * }
+ * ```
+ *
+ * The flatMap is relatively simply defined, we can mimic the existing definition for [Result.map],
+ * though the implementation is uglier because of the `internal` definition for `value`
+ *
+ * ```
+ * inline fun <R, T> Result<T>.flatMap(transform: (value: T) -> Result<R>): Result<R> {
+ * return when {
+ * isSuccess -> transform(getOrThrow())
+ * else -> Result.failure(exceptionOrNull()!!)
+ * }
+ * }
+ * ```
+ */
+
+/**
+ * Given a [transform] that returns a [Result], apply the transform to this result, unwrapping the
+ * return value so that
+ *
+ * These [contract] and [callsInPlace] methods are copied from the [Result.map] definition
+ */
+@OptIn(ExperimentalContracts::class)
+inline fun <R, T> Result<T>.flatMap(transform: (value: T) -> Result<R>): Result<R> {
+ contract { callsInPlace(transform, InvocationKind.AT_MOST_ONCE) }
+
+ return when {
+ // Should never throw, we just don't have access to [this.value]
+ isSuccess -> transform(getOrThrow())
+ // Exception should never be null here
+ else -> Result.failure(exceptionOrNull()!!)
+ }
+}
+
+/**
+ * ValueParser turns a [String] into a Result<A> by applying a transform. See the default
+ * implementations below for starting points. The intention here is to provide the base mappings and
+ * allow clients to attach their own transforms. They are expected to succeed or return null on
+ * failure. The failure is propagated to the command parser as a Result and will fail on any
+ * [Result.failure]
+ */
+fun interface ValueParser<out A> {
+ fun parseValue(value: String): Result<A>
+}
+
+/** Map a [ValueParser] of type A to one of type B, by applying the given [transform] */
+inline fun <A, B> ValueParser<A>.map(crossinline transform: (A) -> B?): ValueParser<B> {
+ return ValueParser<B> { value ->
+ this.parseValue(value).flatMap { a ->
+ transform(a)?.let { b -> Result.success(b) }
+ ?: Result.failure(ArgParseError("Failed to transform value $value"))
+ }
+ }
+}
+
+/**
+ * Base type parsers are provided by the lib, and can be simply composed upon by [ValueParser.map]
+ * functions on the parser
+ */
+
+/** String parsing always succeeds if the value exists */
+private val parseString: ValueParser<String> = ValueParser { value -> Result.success(value) }
+
+private val parseBoolean: ValueParser<Boolean> = ValueParser { value ->
+ value.toBooleanStrictOrNull()?.let { Result.success(it) }
+ ?: Result.failure(ArgParseError("Failed to parse $value as a boolean"))
+}
+
+private val parseInt: ValueParser<Int> = ValueParser { value ->
+ value.toIntOrNull()?.let { Result.success(it) }
+ ?: Result.failure(ArgParseError("Failed to parse $value as an int"))
+}
+
+private val parseFloat: ValueParser<Float> = ValueParser { value ->
+ value.toFloatOrNull()?.let { Result.success(it) }
+ ?: Result.failure(ArgParseError("Failed to parse $value as a float"))
+}
+
+/** Default parsers that can be use as-is, or [map]ped to another type */
+object Type {
+ val Boolean = parseBoolean
+ val Int = parseInt
+ val Float = parseFloat
+ val String = parseString
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/ScreenDecorationsTest.java b/packages/SystemUI/tests/src/com/android/systemui/ScreenDecorationsTest.java
index 79c87cfd1f3e..796e66514afe 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/ScreenDecorationsTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/ScreenDecorationsTest.java
@@ -96,6 +96,7 @@ import com.android.systemui.log.ScreenDecorationsLogger;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.settings.FakeDisplayTracker;
import com.android.systemui.settings.UserTracker;
+import com.android.systemui.statusbar.commandline.CommandRegistry;
import com.android.systemui.statusbar.events.PrivacyDotViewController;
import com.android.systemui.util.concurrency.FakeExecutor;
import com.android.systemui.util.concurrency.FakeThreadFactory;
@@ -139,6 +140,8 @@ public class ScreenDecorationsTest extends SysuiTestCase {
@Mock
private Display mDisplay;
@Mock
+ private CommandRegistry mCommandRegistry;
+ @Mock
private UserTracker mUserTracker;
@Mock
private PrivacyDotViewController mDotViewController;
@@ -231,8 +234,9 @@ public class ScreenDecorationsTest extends SysuiTestCase {
mExecutor,
new ScreenDecorationsLogger(logcatLogBuffer("TestLogBuffer"))));
- mScreenDecorations = spy(new ScreenDecorations(mContext, mExecutor, mSecureSettings,
- mUserTracker, mDisplayTracker, mDotViewController, mThreadFactory,
+ mScreenDecorations = spy(new ScreenDecorations(mContext, mSecureSettings,
+ mCommandRegistry, mUserTracker, mDisplayTracker, mDotViewController,
+ mThreadFactory,
mPrivacyDotDecorProviderFactory, mFaceScanningProviderFactory,
new ScreenDecorationsLogger(logcatLogBuffer("TestLogBuffer")),
mAuthController) {
@@ -1226,8 +1230,9 @@ public class ScreenDecorationsTest extends SysuiTestCase {
mFaceScanningProviders.add(mFaceScanningDecorProvider);
when(mFaceScanningProviderFactory.getProviders()).thenReturn(mFaceScanningProviders);
when(mFaceScanningProviderFactory.getHasProviders()).thenReturn(true);
- ScreenDecorations screenDecorations = new ScreenDecorations(mContext, mExecutor,
- mSecureSettings, mUserTracker, mDisplayTracker, mDotViewController,
+ ScreenDecorations screenDecorations = new ScreenDecorations(mContext,
+ mSecureSettings, mCommandRegistry, mUserTracker, mDisplayTracker,
+ mDotViewController,
mThreadFactory, mPrivacyDotDecorProviderFactory, mFaceScanningProviderFactory,
new ScreenDecorationsLogger(logcatLogBuffer("TestLogBuffer")), mAuthController);
screenDecorations.start();
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt
new file mode 100644
index 000000000000..cfbe8e36537d
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt
@@ -0,0 +1,212 @@
+/*
+ * 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.systemui.statusbar.commandline
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import java.io.PrintWriter
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@SmallTest
+class CommandParserTest : SysuiTestCase() {
+ private val parser = CommandParser()
+
+ @Test
+ fun registerToken_cannotReuseNames() {
+ parser.flag("-f")
+ assertThrows(IllegalArgumentException::class.java) { parser.flag("-f") }
+ }
+
+ @Test
+ fun unknownToken_throws() {
+ assertThrows(ArgParseError::class.java) { parser.parse(listOf("unknown-token")) }
+ }
+
+ @Test
+ fun parseSingleFlag_present() {
+ val flag by parser.flag("-f")
+ parser.parse(listOf("-f"))
+ assertTrue(flag)
+ }
+
+ @Test
+ fun parseSingleFlag_notPresent() {
+ val flag by parser.flag("-f")
+ parser.parse(listOf())
+ assertFalse(flag)
+ }
+
+ @Test
+ fun parseSingleOptionalParam_present() {
+ val param by parser.param("-p", valueParser = Type.Int)
+ parser.parse(listOf("-p", "123"))
+ assertThat(param).isEqualTo(123)
+ }
+
+ @Test
+ fun parseSingleOptionalParam_notPresent() {
+ val param by parser.param("-p", valueParser = Type.Int)
+ parser.parse(listOf())
+ assertThat(param).isNull()
+ }
+
+ @Test
+ fun parseSingleOptionalParam_missingArg_throws() {
+ val param by parser.param("-p", valueParser = Type.Int)
+ assertThrows(ArgParseError::class.java) { parser.parse(listOf("-p")) }
+ }
+
+ @Test
+ fun parseSingleRequiredParam_present() {
+ val param by parser.require(parser.param("-p", valueParser = Type.Int))
+ parser.parse(listOf("-p", "123"))
+ assertThat(param).isEqualTo(123)
+ }
+
+ @Test
+ fun parseSingleRequiredParam_notPresent_failsValidation() {
+ val param by parser.require(parser.param("-p", valueParser = Type.Int))
+ assertFalse(parser.parse(listOf()))
+ }
+
+ @Test
+ fun parseSingleRequiredParam_missingArg_throws() {
+ val param by parser.require(parser.param("-p", valueParser = Type.Int))
+ assertThrows(ArgParseError::class.java) { parser.parse(listOf("-p")) }
+ }
+
+ @Test
+ fun parseAsSubCommand_singleFlag_present() {
+ val flag by parser.flag("-f")
+ val args = listOf("-f").listIterator()
+ parser.parseAsSubCommand(args)
+
+ assertTrue(flag)
+ }
+
+ @Test
+ fun parseAsSubCommand_singleFlag_notPresent() {
+ val flag by parser.flag("-f")
+ val args = listOf("--other-flag").listIterator()
+ parser.parseAsSubCommand(args)
+
+ assertFalse(flag)
+ }
+
+ @Test
+ fun parseAsSubCommand_singleOptionalParam_present() {
+ val param by parser.param("-p", valueParser = Type.Int)
+ parser.parseAsSubCommand(listOf("-p", "123", "--other-arg", "321").listIterator())
+ assertThat(param).isEqualTo(123)
+ }
+
+ @Test
+ fun parseAsSubCommand_singleOptionalParam_notPresent() {
+ val param by parser.param("-p", valueParser = Type.Int)
+ parser.parseAsSubCommand(listOf("--other-arg", "321").listIterator())
+ assertThat(param).isNull()
+ }
+
+ @Test
+ fun parseAsSubCommand_singleRequiredParam_present() {
+ val param by parser.require(parser.param("-p", valueParser = Type.Int))
+ parser.parseAsSubCommand(listOf("-p", "123", "--other-arg", "321").listIterator())
+ assertThat(param).isEqualTo(123)
+ }
+
+ @Test
+ fun parseAsSubCommand_singleRequiredParam_notPresent() {
+ parser.require(parser.param("-p", valueParser = Type.Int))
+ assertFalse(parser.parseAsSubCommand(listOf("--other-arg", "321").listIterator()))
+ }
+
+ @Test
+ fun parseCommandWithSubCommand_required_provided() {
+ val topLevelFlag by parser.flag("flag", shortName = "-f")
+
+ val cmd =
+ object : ParseableCommand("test") {
+ val flag by flag("flag1")
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ parser.require(parser.subCommand(cmd))
+ parser.parse(listOf("-f", "test", "--flag1"))
+
+ assertTrue(topLevelFlag)
+ assertThat(cmd).isNotNull()
+ assertTrue(cmd.flag)
+ }
+
+ @Test
+ fun parseCommandWithSubCommand_required_notProvided() {
+ val topLevelFlag by parser.flag("-f")
+
+ val cmd =
+ object : ParseableCommand("test") {
+ val flag by parser.flag("flag1")
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ parser.require(parser.subCommand(cmd))
+
+ assertFalse(parser.parse(listOf("-f")))
+ }
+
+ @Test
+ fun flag_requiredParam_optionalParam_allProvided_failsValidation() {
+ val flag by parser.flag("-f")
+ val optionalParam by parser.param("-p", valueParser = Type.Int)
+ val requiredParam by parser.require(parser.param("-p2", valueParser = Type.Boolean))
+
+ parser.parse(
+ listOf(
+ "-f",
+ "-p",
+ "123",
+ "-p2",
+ "false",
+ )
+ )
+
+ assertTrue(flag)
+ assertThat(optionalParam).isEqualTo(123)
+ assertFalse(requiredParam)
+ }
+
+ @Test
+ fun flag_requiredParam_optionalParam_optionalExcluded() {
+ val flag by parser.flag("-f")
+ val optionalParam by parser.param("-p", valueParser = Type.Int)
+ val requiredParam by parser.require(parser.param("-p2", valueParser = Type.Boolean))
+
+ parser.parse(
+ listOf(
+ "-p2",
+ "true",
+ )
+ )
+
+ assertFalse(flag)
+ assertThat(optionalParam).isNull()
+ assertTrue(requiredParam)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt
new file mode 100644
index 000000000000..e391d6b11cd6
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt
@@ -0,0 +1,55 @@
+package com.android.systemui.statusbar.commandline
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@SmallTest
+class ParametersTest : SysuiTestCase() {
+ @Test
+ fun singleArgOptional_returnsNullBeforeParse() {
+ val optional by SingleArgParamOptional(longName = "longName", valueParser = Type.Int)
+ assertThat(optional).isNull()
+ }
+
+ @Test
+ fun singleArgOptional_returnsParsedValue() {
+ val param = SingleArgParamOptional(longName = "longName", valueParser = Type.Int)
+ param.parseArgsFromIter(listOf("3").listIterator())
+ val optional by param
+ assertThat(optional).isEqualTo(3)
+ }
+
+ @Test
+ fun singleArgRequired_throwsBeforeParse() {
+ val req by SingleArgParam(longName = "param", valueParser = Type.Boolean)
+ assertThrows(IllegalStateException::class.java) { req }
+ }
+
+ @Test
+ fun singleArgRequired_returnsParsedValue() {
+ val param = SingleArgParam(longName = "param", valueParser = Type.Boolean)
+ param.parseArgsFromIter(listOf("true").listIterator())
+ val req by param
+ assertTrue(req)
+ }
+
+ @Test
+ fun param_handledAfterParse() {
+ val optParam = SingleArgParamOptional(longName = "string1", valueParser = Type.String)
+ val reqParam = SingleArgParam(longName = "string2", valueParser = Type.Float)
+
+ assertFalse(optParam.handled)
+ assertFalse(reqParam.handled)
+
+ optParam.parseArgsFromIter(listOf("test").listIterator())
+ reqParam.parseArgsFromIter(listOf("1.23").listIterator())
+
+ assertTrue(optParam.handled)
+ assertTrue(reqParam.handled)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt
new file mode 100644
index 000000000000..86548d079003
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt
@@ -0,0 +1,317 @@
+/*
+ * 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.systemui.statusbar.commandline
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import java.io.PrintWriter
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+class ParseableCommandTest : SysuiTestCase() {
+ @Mock private lateinit var pw: PrintWriter
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+ }
+
+ /**
+ * A little change-detector-y, but this is just a general assertion that building up a command
+ * parser via its wrapper works as expected.
+ */
+ @Test
+ fun testFactoryMethods() {
+ val mySubCommand =
+ object : ParseableCommand("subCommand") {
+ val flag by flag("flag")
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ val mySubCommand2 =
+ object : ParseableCommand("subCommand2") {
+ val flag by flag("flag")
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ // Verify that the underlying parser contains the correct types
+ val myCommand =
+ object : ParseableCommand("testName") {
+ val flag by flag("flag", shortName = "f")
+ val requiredParam by
+ param(longName = "required-param", shortName = "r", valueParser = Type.String)
+ .required()
+ val optionalParam by
+ param(longName = "optional-param", shortName = "o", valueParser = Type.Boolean)
+ val optionalSubCommand by subCommand(mySubCommand)
+ val requiredSubCommand by subCommand(mySubCommand2).required()
+
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ val flags = myCommand.parser.flags
+ val params = myCommand.parser.params
+ val subCommands = myCommand.parser.subCommands
+
+ assertThat(flags).hasSize(2)
+ assertThat(flags[0]).isInstanceOf(Flag::class.java)
+ assertThat(flags[1]).isInstanceOf(Flag::class.java)
+
+ assertThat(params).hasSize(2)
+ val req = params.filter { it is SingleArgParam<*> }
+ val opt = params.filter { it is SingleArgParamOptional<*> }
+ assertThat(req).hasSize(1)
+ assertThat(opt).hasSize(1)
+
+ val reqSub = subCommands.filter { it is RequiredSubCommand<*> }
+ val optSub = subCommands.filter { it is OptionalSubCommand<*> }
+ assertThat(reqSub).hasSize(1)
+ assertThat(optSub).hasSize(1)
+ }
+
+ @Test
+ fun factoryMethods_enforceShortNameRules() {
+ // Short names MUST be one character long
+ assertThrows(IllegalArgumentException::class.java) {
+ val myCommand =
+ object : ParseableCommand("test-command") {
+ val flag by flag("longName", "invalidShortName")
+
+ override fun execute(pw: PrintWriter) {}
+ }
+ }
+
+ assertThrows(IllegalArgumentException::class.java) {
+ val myCommand =
+ object : ParseableCommand("test-command") {
+ val param by param("longName", "invalidShortName", valueParser = Type.String)
+
+ override fun execute(pw: PrintWriter) {}
+ }
+ }
+ }
+
+ @Test
+ fun factoryMethods_enforceLongNames_notPrefixed() {
+ // Long names must not start with "-", since they will be added
+ assertThrows(IllegalArgumentException::class.java) {
+ val myCommand =
+ object : ParseableCommand("test-command") {
+ val flag by flag("--invalid")
+
+ override fun execute(pw: PrintWriter) {}
+ }
+ }
+
+ assertThrows(IllegalArgumentException::class.java) {
+ val myCommand =
+ object : ParseableCommand("test-command") {
+ val param by param("-invalid", valueParser = Type.String)
+
+ override fun execute(pw: PrintWriter) {}
+ }
+ }
+ }
+
+ @Test
+ fun executeDoesNotPropagateExceptions() {
+ val cmd =
+ object : ParseableCommand("test-command") {
+ val flag by flag("flag")
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ val throwingCommand = listOf("unknown-token")
+
+ // Given a command that would cause an ArgParseError
+ assertThrows(ArgParseError::class.java) { cmd.parser.parse(throwingCommand) }
+
+ // The parser consumes that error
+ cmd.execute(pw, throwingCommand)
+ }
+
+ @Test
+ fun executeFailingCommand_callsOnParseFailed() {
+ val cmd =
+ object : ParseableCommand("test-command") {
+ val flag by flag("flag")
+
+ var onParseFailedCalled = false
+
+ override fun execute(pw: PrintWriter) {}
+ override fun onParseFailed(error: ArgParseError) {
+ onParseFailedCalled = true
+ }
+ }
+
+ val throwingCommand = listOf("unknown-token")
+ cmd.execute(pw, throwingCommand)
+
+ assertTrue(cmd.onParseFailedCalled)
+ }
+
+ @Test
+ fun baseCommand() {
+ val myCommand = MyCommand()
+ myCommand.execute(pw, baseCommand)
+
+ assertThat(myCommand.flag1).isFalse()
+ assertThat(myCommand.singleParam).isNull()
+ }
+
+ @Test
+ fun commandWithFlags() {
+ val command = MyCommand()
+ command.execute(pw, cmdWithFlags)
+
+ assertThat(command.flag1).isTrue()
+ assertThat(command.flag2).isTrue()
+ }
+
+ @Test
+ fun commandWithArgs() {
+ val cmd = MyCommand()
+ cmd.execute(pw, cmdWithSingleArgParam)
+
+ assertThat(cmd.singleParam).isEqualTo("single_param")
+ }
+
+ @Test
+ fun commandWithRequiredParam_provided() {
+ val cmd =
+ object : ParseableCommand(name) {
+ val singleRequiredParam: String by
+ param(
+ longName = "param1",
+ shortName = "p",
+ valueParser = Type.String,
+ )
+ .required()
+
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ val cli = listOf("-p", "value")
+ cmd.execute(pw, cli)
+
+ assertThat(cmd.singleRequiredParam).isEqualTo("value")
+ }
+
+ @Test
+ fun commandWithRequiredParam_not_provided_throws() {
+ val cmd =
+ object : ParseableCommand(name) {
+ val singleRequiredParam by
+ param(shortName = "p", longName = "param1", valueParser = Type.String)
+ .required()
+
+ override fun execute(pw: PrintWriter) {}
+
+ override fun execute(pw: PrintWriter, args: List<String>) {
+ parser.parse(args)
+ execute(pw)
+ }
+ }
+
+ val cli = listOf("")
+ assertThrows(ArgParseError::class.java) { cmd.execute(pw, cli) }
+ }
+
+ @Test
+ fun commandWithSubCommand() {
+ val subName = "sub-command"
+ val subCmd =
+ object : ParseableCommand(subName) {
+ val singleOptionalParam: String? by param("param", valueParser = Type.String)
+
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ val cmd =
+ object : ParseableCommand(name) {
+ val subCmd by subCommand(subCmd)
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ cmd.execute(pw, listOf("sub-command", "--param", "test"))
+ assertThat(cmd.subCmd?.singleOptionalParam).isEqualTo("test")
+ }
+
+ @Test
+ fun complexCommandWithSubCommands_reusedNames() {
+ val commandLine = "-f --param1 arg1 sub-command1 -f -p arg2 --param2 arg3".split(" ")
+
+ val subName = "sub-command1"
+ val subCmd =
+ object : ParseableCommand(subName) {
+ val flag1 by flag("flag", shortName = "f")
+ val param1: String? by param("param1", shortName = "p", valueParser = Type.String)
+
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ val myCommand =
+ object : ParseableCommand(name) {
+ val flag1 by flag(longName = "flag", shortName = "f")
+ val param1 by param("param1", shortName = "p", valueParser = Type.String).required()
+ val param2: String? by param(longName = "param2", valueParser = Type.String)
+ val subCommand by subCommand(subCmd)
+
+ override fun execute(pw: PrintWriter) {}
+ }
+
+ myCommand.execute(pw, commandLine)
+
+ assertThat(myCommand.flag1).isTrue()
+ assertThat(myCommand.param1).isEqualTo("arg1")
+ assertThat(myCommand.param2).isEqualTo("arg3")
+ assertThat(myCommand.subCommand).isNotNull()
+ assertThat(myCommand.subCommand?.flag1).isTrue()
+ assertThat(myCommand.subCommand?.param1).isEqualTo("arg2")
+ }
+
+ class MyCommand(
+ private val onExecute: ((MyCommand) -> Unit)? = null,
+ ) : ParseableCommand(name) {
+
+ val flag1 by flag(shortName = "f", longName = "flag1", description = "flag 1 for test")
+ val flag2 by flag(shortName = "g", longName = "flag2", description = "flag 2 for test")
+ val singleParam: String? by
+ param(
+ shortName = "a",
+ longName = "arg1",
+ valueParser = Type.String,
+ )
+
+ override fun execute(pw: PrintWriter) {
+ onExecute?.invoke(this)
+ }
+ }
+
+ companion object {
+ const val name = "my_command"
+ val baseCommand = listOf("")
+ val cmdWithFlags = listOf("-f", "--flag2")
+ val cmdWithSingleArgParam = listOf("--arg1", "single_param")
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt
new file mode 100644
index 000000000000..759f0bcd6ea8
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt
@@ -0,0 +1,61 @@
+package com.android.systemui.statusbar.commandline
+
+import android.graphics.Rect
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@SmallTest
+class ValueParserTest : SysuiTestCase() {
+ @Test
+ fun parseString() {
+ assertThat(Type.String.parseValue("test")).isEqualTo(Result.success("test"))
+ }
+
+ @Test
+ fun parseInt() {
+ assertThat(Type.Int.parseValue("123")).isEqualTo(Result.success(123))
+
+ assertTrue(Type.Int.parseValue("not an Int").isFailure)
+ }
+
+ @Test
+ fun parseFloat() {
+ assertThat(Type.Float.parseValue("1.23")).isEqualTo(Result.success(1.23f))
+
+ assertTrue(Type.Int.parseValue("not a Float").isFailure)
+ }
+
+ @Test
+ fun parseBoolean() {
+ assertThat(Type.Boolean.parseValue("true")).isEqualTo(Result.success(true))
+ assertThat(Type.Boolean.parseValue("false")).isEqualTo(Result.success(false))
+
+ assertTrue(Type.Boolean.parseValue("not a Boolean").isFailure)
+ }
+
+ @Test
+ fun mapToComplexType() {
+ val parseSquare = Type.Int.map { Rect(it, it, it, it) }
+
+ assertThat(parseSquare.parseValue("10")).isEqualTo(Result.success(Rect(10, 10, 10, 10)))
+ }
+
+ @Test
+ fun mapToFallibleComplexType() {
+ val fallibleParseSquare =
+ Type.Int.map {
+ if (it > 0) {
+ Rect(it, it, it, it)
+ } else {
+ null
+ }
+ }
+
+ assertThat(fallibleParseSquare.parseValue("10"))
+ .isEqualTo(Result.success(Rect(10, 10, 10, 10)))
+ assertTrue(fallibleParseSquare.parseValue("-10").isFailure)
+ }
+}