diff options
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) + } +} |