Controls API - Support action while device is locked

By default, all user interactions with a device control while the
device is locked will require authorization. Add a new field to the
Control class that allows the application that builds the control to
determine if the device requires authorization or not.

Fixes: 202978576
Test: atest ControlActionCoordinatorImplTest
ControlProviderServiceTest
Test: atest CtsControlsDeviceTestCases CtsControlBuilderTest CtsControlTemplateTest

Change-Id: I294662b88637e18bf620aff2eed4749ae304ebd4
This commit is contained in:
Matt Pietal
2021-10-21 10:33:09 -04:00
parent 809adc7c21
commit d689c6ad2d
6 changed files with 110 additions and 29 deletions

View File

@@ -38374,6 +38374,7 @@ package android.service.controls {
method @NonNull public CharSequence getSubtitle();
method @NonNull public CharSequence getTitle();
method @Nullable public CharSequence getZone();
method public boolean isAuthRequired();
method public void writeToParcel(@NonNull android.os.Parcel, int);
field @NonNull public static final android.os.Parcelable.Creator<android.service.controls.Control> CREATOR;
field public static final int STATUS_DISABLED = 4; // 0x4
@@ -38388,6 +38389,7 @@ package android.service.controls {
ctor public Control.StatefulBuilder(@NonNull android.service.controls.Control);
method @NonNull public android.service.controls.Control build();
method @NonNull public android.service.controls.Control.StatefulBuilder setAppIntent(@NonNull android.app.PendingIntent);
method @NonNull public android.service.controls.Control.StatefulBuilder setAuthRequired(boolean);
method @NonNull public android.service.controls.Control.StatefulBuilder setControlId(@NonNull String);
method @NonNull public android.service.controls.Control.StatefulBuilder setControlTemplate(@NonNull android.service.controls.templates.ControlTemplate);
method @NonNull public android.service.controls.Control.StatefulBuilder setCustomColor(@Nullable android.content.res.ColorStateList);

View File

@@ -121,6 +121,7 @@ public final class Control implements Parcelable {
private final @Status int mStatus;
private final @NonNull ControlTemplate mControlTemplate;
private final @NonNull CharSequence mStatusText;
private final boolean mAuthRequired;
/**
* @param controlId the unique persistent identifier for this object.
@@ -137,6 +138,8 @@ public final class Control implements Parcelable {
* @param status
* @param controlTemplate
* @param statusText
* @param authRequired true if the control can not be interacted with until the device is
* unlocked
*/
Control(@NonNull String controlId,
@DeviceTypes.DeviceType int deviceType,
@@ -149,7 +152,8 @@ public final class Control implements Parcelable {
@Nullable ColorStateList customColor,
@Status int status,
@NonNull ControlTemplate controlTemplate,
@NonNull CharSequence statusText) {
@NonNull CharSequence statusText,
boolean authRequired) {
Preconditions.checkNotNull(controlId);
Preconditions.checkNotNull(title);
Preconditions.checkNotNull(subtitle);
@@ -180,6 +184,7 @@ public final class Control implements Parcelable {
}
mControlTemplate = controlTemplate;
mStatusText = statusText;
mAuthRequired = authRequired;
}
/**
@@ -219,6 +224,7 @@ public final class Control implements Parcelable {
ControlTemplateWrapper wrapper = ControlTemplateWrapper.CREATOR.createFromParcel(in);
mControlTemplate = wrapper.getWrappedTemplate();
mStatusText = in.readCharSequence();
mAuthRequired = in.readBoolean();
}
/**
@@ -336,6 +342,13 @@ public final class Control implements Parcelable {
return mStatusText;
}
/**
* @return true if the control can not be interacted with until the device is unlocked
*/
public boolean isAuthRequired() {
return mAuthRequired;
}
@Override
public int describeContents() {
return 0;
@@ -376,6 +389,7 @@ public final class Control implements Parcelable {
dest.writeInt(mStatus);
new ControlTemplateWrapper(mControlTemplate).writeToParcel(dest, flags);
dest.writeCharSequence(mStatusText);
dest.writeBoolean(mAuthRequired);
}
public static final @NonNull Creator<Control> CREATOR = new Creator<Control>() {
@@ -409,6 +423,7 @@ public final class Control implements Parcelable {
* <li> Status: {@link Status#STATUS_UNKNOWN}
* <li> Control template: {@link ControlTemplate#getNoTemplateObject}
* <li> Status text: {@code ""}
* <li> Auth Required: {@code true}
* </ul>
*/
@SuppressLint("MutableBareField")
@@ -585,7 +600,8 @@ public final class Control implements Parcelable {
mCustomColor,
STATUS_UNKNOWN,
ControlTemplate.NO_TEMPLATE,
"");
"",
true /* authRequired */);
}
}
@@ -607,6 +623,7 @@ public final class Control implements Parcelable {
* <li> Status: {@link Status#STATUS_UNKNOWN}
* <li> Control template: {@link ControlTemplate#getNoTemplateObject}
* <li> Status text: {@code ""}
* <li> Auth Required: {@code true}
* </ul>
*/
public static final class StatefulBuilder {
@@ -623,6 +640,7 @@ public final class Control implements Parcelable {
private @Status int mStatus = STATUS_UNKNOWN;
private @NonNull ControlTemplate mControlTemplate = ControlTemplate.NO_TEMPLATE;
private @NonNull CharSequence mStatusText = "";
private boolean mAuthRequired = true;
/**
* @param controlId the identifier for the {@link Control}.
@@ -655,6 +673,7 @@ public final class Control implements Parcelable {
mStatus = control.mStatus;
mControlTemplate = control.mControlTemplate;
mStatusText = control.mStatusText;
mAuthRequired = control.mAuthRequired;
}
/**
@@ -820,6 +839,17 @@ public final class Control implements Parcelable {
return this;
}
/**
* @param authRequired true if the control can not be interacted with until the device is
* unlocked
* @return {@code this}
*/
@NonNull
public StatefulBuilder setAuthRequired(boolean authRequired) {
mAuthRequired = authRequired;
return this;
}
/**
* @return a valid {@link Control}
*/
@@ -836,7 +866,8 @@ public final class Control implements Parcelable {
mCustomColor,
mStatus,
mControlTemplate,
mStatusText);
mStatusText,
mAuthRequired);
}
}
}

View File

@@ -116,6 +116,9 @@ public abstract class ControlsProviderService extends Service {
* Calls to {@link Subscriber#onComplete} will not be expected. Instead, wait for the call from
* {@link Subscription#cancel} to indicate that updates are no longer required. It is expected
* that controls provided by this publisher were created using {@link Control.StatefulBuilder}.
*
* By default, all controls require the device to be unlocked in order for the user to interact
* with it. This can be modified per Control by {@link Control.StatefulBuilder#setAuthRequired}.
*/
@NonNull
public abstract Publisher<Control> createPublisherFor(@NonNull List<String> controlIds);
@@ -127,6 +130,9 @@ public abstract class ControlsProviderService extends Service {
* {@link ControlAction.ResponseResult}. The Integer should indicate whether the action
* was received successfully, or if additional prompts should be presented to
* the user. Any visual control updates should be sent via the Publisher.
* By default, all invocations of this method will require the device be unlocked. This can
* be modified per Control by {@link Control.StatefulBuilder#setAuthRequired}.
*/
public abstract void performControlAction(@NonNull String controlId,
@NonNull ControlAction action, @NonNull Consumer<Integer> consumer);

View File

@@ -347,6 +347,7 @@ public class ControlProviderServiceTest {
&& Objects.equals(c1.getCustomColor(), c2.getCustomColor())
&& c1.getStatus() == c2.getStatus()
&& Objects.equals(c1.getControlTemplate(), c2.getControlTemplate())
&& Objects.equals(c1.isAuthRequired(), c2.isAuthRequired())
&& Objects.equals(c1.getStatusText(), c2.getStatusText());
}

View File

@@ -77,23 +77,37 @@ class ControlActionCoordinatorImpl @Inject constructor(
override fun toggle(cvh: ControlViewHolder, templateId: String, isChecked: Boolean) {
controlsMetricsLogger.touch(cvh, isLocked)
bouncerOrRun(createAction(cvh.cws.ci.controlId, {
cvh.layout.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
cvh.action(BooleanAction(templateId, !isChecked))
}, true /* blockable */))
bouncerOrRun(
createAction(
cvh.cws.ci.controlId,
{
cvh.layout.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
cvh.action(BooleanAction(templateId, !isChecked))
},
true /* blockable */
),
isAuthRequired(cvh)
)
}
override fun touch(cvh: ControlViewHolder, templateId: String, control: Control) {
controlsMetricsLogger.touch(cvh, isLocked)
val blockable = cvh.usePanel()
bouncerOrRun(createAction(cvh.cws.ci.controlId, {
cvh.layout.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
if (cvh.usePanel()) {
showDetail(cvh, control.getAppIntent())
} else {
cvh.action(CommandAction(templateId))
}
}, blockable))
bouncerOrRun(
createAction(
cvh.cws.ci.controlId,
{
cvh.layout.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
if (cvh.usePanel()) {
showDetail(cvh, control.getAppIntent())
} else {
cvh.action(CommandAction(templateId))
}
},
blockable
),
isAuthRequired(cvh)
)
}
override fun drag(isEdge: Boolean) {
@@ -106,20 +120,33 @@ class ControlActionCoordinatorImpl @Inject constructor(
override fun setValue(cvh: ControlViewHolder, templateId: String, newValue: Float) {
controlsMetricsLogger.drag(cvh, isLocked)
bouncerOrRun(createAction(cvh.cws.ci.controlId, {
cvh.action(FloatAction(templateId, newValue))
}, false /* blockable */))
bouncerOrRun(
createAction(
cvh.cws.ci.controlId,
{ cvh.action(FloatAction(templateId, newValue)) },
false /* blockable */
),
isAuthRequired(cvh)
)
}
override fun longPress(cvh: ControlViewHolder) {
controlsMetricsLogger.longPress(cvh, isLocked)
bouncerOrRun(createAction(cvh.cws.ci.controlId, {
// Long press snould only be called when there is valid control state, otherwise ignore
cvh.cws.control?.let {
cvh.layout.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
showDetail(cvh, it.getAppIntent())
}
}, false /* blockable */))
bouncerOrRun(
createAction(
cvh.cws.ci.controlId,
{
// Long press snould only be called when there is valid control state,
// otherwise ignore
cvh.cws.control?.let {
cvh.layout.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
showDetail(cvh, it.getAppIntent())
}
},
false /* blockable */
),
isAuthRequired(cvh)
)
}
override fun runPendingAction(controlId: String) {
@@ -135,6 +162,8 @@ class ControlActionCoordinatorImpl @Inject constructor(
actionsInProgress.remove(controlId)
}
private fun isAuthRequired(cvh: ControlViewHolder) = cvh.cws.control?.isAuthRequired() ?: true
private fun shouldRunAction(controlId: String) =
if (actionsInProgress.add(controlId)) {
uiExecutor.executeDelayed({
@@ -146,8 +175,8 @@ class ControlActionCoordinatorImpl @Inject constructor(
}
@VisibleForTesting
fun bouncerOrRun(action: Action) {
if (keyguardStateController.isShowing()) {
fun bouncerOrRun(action: Action, authRequired: Boolean) {
if (keyguardStateController.isShowing() && authRequired) {
if (isLocked) {
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))

View File

@@ -94,6 +94,7 @@ class ControlActionCoordinatorImplTest : SysuiTestCase() {
))
`when`(cvh.cws.ci.controlId).thenReturn(ID)
`when`(cvh.cws.control?.isAuthRequired()).thenReturn(true)
action = spy(coordinator.Action(ID, {}, false))
doReturn(action).`when`(coordinator).createAction(any(), any(), anyBoolean())
}
@@ -103,7 +104,7 @@ class ControlActionCoordinatorImplTest : SysuiTestCase() {
`when`(keyguardStateController.isShowing()).thenReturn(false)
coordinator.toggle(cvh, "", true)
verify(coordinator).bouncerOrRun(action)
verify(coordinator).bouncerOrRun(action, true /*authRequired */)
verify(action).invoke()
}
@@ -113,7 +114,7 @@ class ControlActionCoordinatorImplTest : SysuiTestCase() {
`when`(keyguardStateController.isUnlocked()).thenReturn(false)
coordinator.toggle(cvh, "", true)
verify(coordinator).bouncerOrRun(action)
verify(coordinator).bouncerOrRun(action, true /*authRequired */)
verify(activityStarter).dismissKeyguardThenExecute(any(), any(), anyBoolean())
verify(action, never()).invoke()
@@ -127,4 +128,15 @@ class ControlActionCoordinatorImplTest : SysuiTestCase() {
coordinator.runPendingAction(ID)
verify(action).invoke()
}
@Test
fun testToggleRunsWhenLockedAndAuthNotRequired() {
`when`(keyguardStateController.isShowing()).thenReturn(true)
`when`(keyguardStateController.isUnlocked()).thenReturn(false)
`when`(cvh.cws.control?.isAuthRequired()).thenReturn(false)
coordinator.toggle(cvh, "", true)
verify(coordinator).bouncerOrRun(action, false /* authRequired */)
verify(action).invoke()
}
}