fix: 引入Settings的Module

This commit is contained in:
2024-12-10 14:57:24 +08:00
parent ad8fc8731d
commit df105485bd
6934 changed files with 896168 additions and 2 deletions

View File

@@ -0,0 +1,266 @@
/*
* Copyright (C) 2021 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.car.qc;
import android.app.PendingIntent;
import android.content.Context;
import android.graphics.drawable.Icon;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
/**
* Quick Control Action that are includes as either start or end actions in {@link QCRow}
*/
public class QCActionItem extends QCItem {
private final boolean mIsChecked;
private final boolean mIsAvailable;
private final boolean mIsClickable;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
private String mContentDescription;
public QCActionItem(@NonNull @QCItemType String type, boolean isChecked, boolean isEnabled,
boolean isAvailable, boolean isClickable, boolean isClickableWhileDisabled,
@Nullable Icon icon, @Nullable String contentDescription,
@Nullable PendingIntent action, @Nullable PendingIntent disabledClickAction) {
super(type, isEnabled, isClickableWhileDisabled);
mIsChecked = isChecked;
mIsAvailable = isAvailable;
mIsClickable = isClickable;
mIcon = icon;
mContentDescription = contentDescription;
mAction = action;
mDisabledClickAction = disabledClickAction;
}
public QCActionItem(@NonNull Parcel in) {
super(in);
mIsChecked = in.readBoolean();
mIsAvailable = in.readBoolean();
mIsClickable = in.readBoolean();
boolean hasIcon = in.readBoolean();
if (hasIcon) {
mIcon = Icon.CREATOR.createFromParcel(in);
}
boolean hasContentDescription = in.readBoolean();
if (hasContentDescription) {
mContentDescription = in.readString();
}
boolean hasAction = in.readBoolean();
if (hasAction) {
mAction = PendingIntent.CREATOR.createFromParcel(in);
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = PendingIntent.CREATOR.createFromParcel(in);
}
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeBoolean(mIsChecked);
dest.writeBoolean(mIsAvailable);
dest.writeBoolean(mIsClickable);
boolean includeIcon = getType().equals(QC_TYPE_ACTION_TOGGLE) && mIcon != null;
dest.writeBoolean(includeIcon);
if (includeIcon) {
mIcon.writeToParcel(dest, flags);
}
boolean hasContentDescription = mContentDescription != null;
dest.writeBoolean(hasContentDescription);
if (hasContentDescription) {
dest.writeString(mContentDescription);
}
boolean hasAction = mAction != null;
dest.writeBoolean(hasAction);
if (hasAction) {
mAction.writeToParcel(dest, flags);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
public boolean isChecked() {
return mIsChecked;
}
public boolean isAvailable() {
return mIsAvailable;
}
public boolean isClickable() {
return mIsClickable;
}
@Nullable
public Icon getIcon() {
return mIcon;
}
@Nullable
public String getContentDescription() {
return mContentDescription;
}
public static Creator<QCActionItem> CREATOR = new Creator<QCActionItem>() {
@Override
public QCActionItem createFromParcel(Parcel source) {
return new QCActionItem(source);
}
@Override
public QCActionItem[] newArray(int size) {
return new QCActionItem[size];
}
};
/**
* Builder for {@link QCActionItem}.
*/
public static class Builder {
private final String mType;
private boolean mIsChecked;
private boolean mIsEnabled = true;
private boolean mIsAvailable = true;
private boolean mIsClickable = true;
private boolean mIsClickableWhileDisabled = false;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
private String mContentDescription;
public Builder(@NonNull @QCItemType String type) {
if (!isValidType(type)) {
throw new IllegalArgumentException("Invalid QCActionItem type provided" + type);
}
mType = type;
}
/**
* Sets whether or not the action item should be checked.
*/
public Builder setChecked(boolean checked) {
mIsChecked = checked;
return this;
}
/**
* Sets whether or not the action item should be enabled.
*/
public Builder setEnabled(boolean enabled) {
mIsEnabled = enabled;
return this;
}
/**
* Sets whether or not the action item is available.
*/
public Builder setAvailable(boolean available) {
mIsAvailable = available;
return this;
}
/**
* Sets whether the action is clickable. This differs from available in that the style will
* remain as if it's enabled/available but click actions will not be processed.
*/
public Builder setClickable(boolean clickable) {
mIsClickable = clickable;
return this;
}
/**
* Sets whether or not an action item should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
return this;
}
/**
* Sets the icon for {@link QC_TYPE_ACTION_TOGGLE} actions
*/
public Builder setIcon(@Nullable Icon icon) {
mIcon = icon;
return this;
}
/**
* Sets the content description
*/
public Builder setContentDescription(@Nullable String contentDescription) {
mContentDescription = contentDescription;
return this;
}
/**
* Sets the string resource to use for content description
*/
public Builder setContentDescription(@NonNull Context context,
@StringRes int contentDescriptionResId) {
mContentDescription = context.getString(contentDescriptionResId);
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked.
*/
public Builder setAction(@Nullable PendingIntent action) {
mAction = action;
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = action;
return this;
}
/**
* Builds the final {@link QCActionItem}.
*/
public QCActionItem build() {
return new QCActionItem(mType, mIsChecked, mIsEnabled, mIsAvailable, mIsClickable,
mIsClickableWhileDisabled, mIcon, mContentDescription, mAction,
mDisabledClickAction);
}
private boolean isValidType(String type) {
return type.equals(QC_TYPE_ACTION_SWITCH) || type.equals(QC_TYPE_ACTION_TOGGLE);
}
}
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright (C) 2021 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.car.qc;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringDef;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Base class for all quick controls elements.
*/
public abstract class QCItem implements Parcelable {
public static final String QC_TYPE_LIST = "QC_TYPE_LIST";
public static final String QC_TYPE_ROW = "QC_TYPE_ROW";
public static final String QC_TYPE_TILE = "QC_TYPE_TILE";
public static final String QC_TYPE_SLIDER = "QC_TYPE_SLIDER";
public static final String QC_TYPE_ACTION_SWITCH = "QC_TYPE_ACTION_SWITCH";
public static final String QC_TYPE_ACTION_TOGGLE = "QC_TYPE_ACTION_TOGGLE";
public static final String QC_ACTION_TOGGLE_STATE = "QC_ACTION_TOGGLE_STATE";
public static final String QC_ACTION_SLIDER_VALUE = "QC_ACTION_SLIDER_VALUE";
@StringDef(value = {
QC_TYPE_LIST,
QC_TYPE_ROW,
QC_TYPE_TILE,
QC_TYPE_SLIDER,
QC_TYPE_ACTION_SWITCH,
QC_TYPE_ACTION_TOGGLE,
})
@Retention(RetentionPolicy.SOURCE)
public @interface QCItemType {
}
private final String mType;
private final boolean mIsEnabled;
private final boolean mIsClickableWhileDisabled;
private ActionHandler mActionHandler;
private ActionHandler mDisabledClickActionHandler;
public QCItem(@NonNull @QCItemType String type) {
this(type, /* isEnabled= */true, /* isClickableWhileDisabled= */ false);
}
public QCItem(@NonNull @QCItemType String type, boolean isEnabled,
boolean isClickableWhileDisabled) {
mType = type;
mIsEnabled = isEnabled;
mIsClickableWhileDisabled = isClickableWhileDisabled;
}
public QCItem(@NonNull Parcel in) {
mType = in.readString();
mIsEnabled = in.readBoolean();
mIsClickableWhileDisabled = in.readBoolean();
}
@NonNull
@QCItemType
public String getType() {
return mType;
}
public boolean isEnabled() {
return mIsEnabled;
}
public boolean isClickableWhileDisabled() {
return mIsClickableWhileDisabled;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mType);
dest.writeBoolean(mIsEnabled);
dest.writeBoolean(mIsClickableWhileDisabled);
}
public void setActionHandler(@Nullable ActionHandler handler) {
mActionHandler = handler;
}
public void setDisabledClickActionHandler(@Nullable ActionHandler handler) {
mDisabledClickActionHandler = handler;
}
@Nullable
public ActionHandler getActionHandler() {
return mActionHandler;
}
@Nullable
public ActionHandler getDisabledClickActionHandler() {
return mDisabledClickActionHandler;
}
/**
* Returns the PendingIntent that is sent when the item is clicked.
*/
@Nullable
public abstract PendingIntent getPrimaryAction();
/**
* Returns the PendingIntent that is sent when the item is clicked while disabled.
*/
@Nullable
public abstract PendingIntent getDisabledClickAction();
/**
* Action handler that can listen for an action to occur and notify listeners.
*/
public interface ActionHandler {
/**
* Callback when an action occurs.
* @param item the QCItem that sent the action
* @param context the context for the action
* @param intent the intent that was sent with the action
*/
void onAction(@NonNull QCItem item, @NonNull Context context, @NonNull Intent intent);
default boolean isActivity() {
return false;
}
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2021 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.car.qc;
import android.app.PendingIntent;
import android.os.Parcel;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Wrapping quick controls element that contains QCRow elements.
*/
public class QCList extends QCItem {
private final List<QCRow> mRows;
public QCList(@NonNull List<QCRow> rows) {
super(QC_TYPE_LIST);
mRows = Collections.unmodifiableList(rows);
}
public QCList(@NonNull Parcel in) {
super(in);
int rowCount = in.readInt();
List<QCRow> rows = new ArrayList<>();
for (int i = 0; i < rowCount; i++) {
rows.add(QCRow.CREATOR.createFromParcel(in));
}
mRows = Collections.unmodifiableList(rows);
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(mRows.size());
for (QCRow row : mRows) {
row.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return null;
}
@Override
public PendingIntent getDisabledClickAction() {
return null;
}
@NonNull
public List<QCRow> getRows() {
return mRows;
}
public static Creator<QCList> CREATOR = new Creator<QCList>() {
@Override
public QCList createFromParcel(Parcel source) {
return new QCList(source);
}
@Override
public QCList[] newArray(int size) {
return new QCList[size];
}
};
/**
* Builder for {@link QCList}.
*/
public static class Builder {
private final List<QCRow> mRows = new ArrayList<>();
/**
* Adds a {@link QCRow} to the list.
*/
public Builder addRow(@NonNull QCRow row) {
mRows.add(row);
return this;
}
/**
* Builds the final {@link QCList}.
*/
public QCList build() {
return new QCList(mRows);
}
}
}

View File

@@ -0,0 +1,315 @@
/*
* Copyright (C) 2021 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.car.qc;
import android.app.PendingIntent;
import android.graphics.drawable.Icon;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Quick Control Row Element
* ------------------------------------
* | | Title | |
* | StartItems | Subtitle | EndItems |
* | | Sliders | |
* ------------------------------------
*/
public class QCRow extends QCItem {
private final String mTitle;
private final String mSubtitle;
private final Icon mStartIcon;
private final boolean mIsStartIconTintable;
private final QCSlider mSlider;
private final List<QCActionItem> mStartItems;
private final List<QCActionItem> mEndItems;
private final PendingIntent mPrimaryAction;
private PendingIntent mDisabledClickAction;
public QCRow(@Nullable String title, @Nullable String subtitle, boolean isEnabled,
boolean isClickableWhileDisabled, @Nullable PendingIntent primaryAction,
@Nullable PendingIntent disabledClickAction, @Nullable Icon startIcon,
boolean isIconTintable, @Nullable QCSlider slider,
@NonNull List<QCActionItem> startItems, @NonNull List<QCActionItem> endItems) {
super(QC_TYPE_ROW, isEnabled, isClickableWhileDisabled);
mTitle = title;
mSubtitle = subtitle;
mPrimaryAction = primaryAction;
mDisabledClickAction = disabledClickAction;
mStartIcon = startIcon;
mIsStartIconTintable = isIconTintable;
mSlider = slider;
mStartItems = Collections.unmodifiableList(startItems);
mEndItems = Collections.unmodifiableList(endItems);
}
public QCRow(@NonNull Parcel in) {
super(in);
mTitle = in.readString();
mSubtitle = in.readString();
boolean hasIcon = in.readBoolean();
if (hasIcon) {
mStartIcon = Icon.CREATOR.createFromParcel(in);
} else {
mStartIcon = null;
}
mIsStartIconTintable = in.readBoolean();
boolean hasSlider = in.readBoolean();
if (hasSlider) {
mSlider = QCSlider.CREATOR.createFromParcel(in);
} else {
mSlider = null;
}
List<QCActionItem> startItems = new ArrayList<>();
int startItemCount = in.readInt();
for (int i = 0; i < startItemCount; i++) {
startItems.add(QCActionItem.CREATOR.createFromParcel(in));
}
mStartItems = Collections.unmodifiableList(startItems);
List<QCActionItem> endItems = new ArrayList<>();
int endItemCount = in.readInt();
for (int i = 0; i < endItemCount; i++) {
endItems.add(QCActionItem.CREATOR.createFromParcel(in));
}
mEndItems = Collections.unmodifiableList(endItems);
boolean hasPrimaryAction = in.readBoolean();
if (hasPrimaryAction) {
mPrimaryAction = PendingIntent.CREATOR.createFromParcel(in);
} else {
mPrimaryAction = null;
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = PendingIntent.CREATOR.createFromParcel(in);
} else {
mDisabledClickAction = null;
}
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeString(mTitle);
dest.writeString(mSubtitle);
boolean hasStartIcon = mStartIcon != null;
dest.writeBoolean(hasStartIcon);
if (hasStartIcon) {
mStartIcon.writeToParcel(dest, flags);
}
dest.writeBoolean(mIsStartIconTintable);
boolean hasSlider = mSlider != null;
dest.writeBoolean(hasSlider);
if (hasSlider) {
mSlider.writeToParcel(dest, flags);
}
dest.writeInt(mStartItems.size());
for (QCActionItem startItem : mStartItems) {
startItem.writeToParcel(dest, flags);
}
dest.writeInt(mEndItems.size());
for (QCActionItem endItem : mEndItems) {
endItem.writeToParcel(dest, flags);
}
boolean hasPrimaryAction = mPrimaryAction != null;
dest.writeBoolean(hasPrimaryAction);
if (hasPrimaryAction) {
mPrimaryAction.writeToParcel(dest, flags);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mPrimaryAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
@Nullable
public String getTitle() {
return mTitle;
}
@Nullable
public String getSubtitle() {
return mSubtitle;
}
@Nullable
public Icon getStartIcon() {
return mStartIcon;
}
public boolean isStartIconTintable() {
return mIsStartIconTintable;
}
@Nullable
public QCSlider getSlider() {
return mSlider;
}
@NonNull
public List<QCActionItem> getStartItems() {
return mStartItems;
}
@NonNull
public List<QCActionItem> getEndItems() {
return mEndItems;
}
public static Creator<QCRow> CREATOR = new Creator<QCRow>() {
@Override
public QCRow createFromParcel(Parcel source) {
return new QCRow(source);
}
@Override
public QCRow[] newArray(int size) {
return new QCRow[size];
}
};
/**
* Builder for {@link QCRow}.
*/
public static class Builder {
private final List<QCActionItem> mStartItems = new ArrayList<>();
private final List<QCActionItem> mEndItems = new ArrayList<>();
private Icon mStartIcon;
private boolean mIsStartIconTintable = true;
private String mTitle;
private String mSubtitle;
private boolean mIsEnabled = true;
private boolean mIsClickableWhileDisabled = false;
private QCSlider mSlider;
private PendingIntent mPrimaryAction;
private PendingIntent mDisabledClickAction;
/**
* Sets the row title.
*/
public Builder setTitle(@Nullable String title) {
mTitle = title;
return this;
}
/**
* Sets the row subtitle.
*/
public Builder setSubtitle(@Nullable String subtitle) {
mSubtitle = subtitle;
return this;
}
/**
* Sets whether or not the row is enabled. Note that this only affects the main row area,
* not the action items contained within the row.
*/
public Builder setEnabled(boolean enabled) {
mIsEnabled = enabled;
return this;
}
/**
* Sets whether or not the row should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
return this;
}
/**
* Sets the row icon.
*/
public Builder setIcon(@Nullable Icon icon) {
mStartIcon = icon;
return this;
}
/**
* Sets whether or not the row icon is tintable.
*/
public Builder setIconTintable(boolean tintable) {
mIsStartIconTintable = tintable;
return this;
}
/**
* Adds a {@link QCSlider} to the slider area.
*/
public Builder addSlider(@Nullable QCSlider slider) {
mSlider = slider;
return this;
}
/**
* Sets the PendingIntent to be sent when the row is clicked.
*/
public Builder setPrimaryAction(@Nullable PendingIntent action) {
mPrimaryAction = action;
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = action;
return this;
}
/**
* Adds a {@link QCActionItem} to the start items area.
*/
public Builder addStartItem(@NonNull QCActionItem item) {
mStartItems.add(item);
return this;
}
/**
* Adds a {@link QCActionItem} to the end items area.
*/
public Builder addEndItem(@NonNull QCActionItem item) {
mEndItems.add(item);
return this;
}
/**
* Builds the final {@link QCRow}.
*/
public QCRow build() {
return new QCRow(mTitle, mSubtitle, mIsEnabled, mIsClickableWhileDisabled,
mPrimaryAction, mDisabledClickAction, mStartIcon, mIsStartIconTintable,
mSlider, mStartItems, mEndItems);
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright (C) 2021 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.car.qc;
import android.app.PendingIntent;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Quick Control Slider included in {@link QCRow}
*/
public class QCSlider extends QCItem {
private int mMin = 0;
private int mMax = 100;
private int mValue = 0;
private PendingIntent mInputAction;
private PendingIntent mDisabledClickAction;
public QCSlider(int min, int max, int value, boolean enabled, boolean clickableWhileDisabled,
@Nullable PendingIntent inputAction, @Nullable PendingIntent disabledClickAction) {
super(QC_TYPE_SLIDER, enabled, clickableWhileDisabled);
mMin = min;
mMax = max;
mValue = value;
mInputAction = inputAction;
mDisabledClickAction = disabledClickAction;
}
public QCSlider(@NonNull Parcel in) {
super(in);
mMin = in.readInt();
mMax = in.readInt();
mValue = in.readInt();
boolean hasAction = in.readBoolean();
if (hasAction) {
mInputAction = PendingIntent.CREATOR.createFromParcel(in);
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = PendingIntent.CREATOR.createFromParcel(in);
}
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(mMin);
dest.writeInt(mMax);
dest.writeInt(mValue);
boolean hasAction = mInputAction != null;
dest.writeBoolean(hasAction);
if (hasAction) {
mInputAction.writeToParcel(dest, flags);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mInputAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
public int getMin() {
return mMin;
}
public int getMax() {
return mMax;
}
public int getValue() {
return mValue;
}
public static Creator<QCSlider> CREATOR = new Creator<QCSlider>() {
@Override
public QCSlider createFromParcel(Parcel source) {
return new QCSlider(source);
}
@Override
public QCSlider[] newArray(int size) {
return new QCSlider[size];
}
};
/**
* Builder for {@link QCSlider}.
*/
public static class Builder {
private int mMin = 0;
private int mMax = 100;
private int mValue = 0;
private boolean mIsEnabled = true;
private boolean mIsClickableWhileDisabled = false;
private PendingIntent mInputAction;
private PendingIntent mDisabledClickAction;
/**
* Set the minimum allowed value for the slider input.
*/
public Builder setMin(int min) {
mMin = min;
return this;
}
/**
* Set the maximum allowed value for the slider input.
*/
public Builder setMax(int max) {
mMax = max;
return this;
}
/**
* Set the current value for the slider input.
*/
public Builder setValue(int value) {
mValue = value;
return this;
}
/**
* Sets whether or not the slider is enabled.
*/
public Builder setEnabled(boolean enabled) {
mIsEnabled = enabled;
return this;
}
/**
* Sets whether or not a slider should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
return this;
}
/**
* Set the PendingIntent to be sent when the slider value is changed.
*/
public Builder setInputAction(@Nullable PendingIntent inputAction) {
mInputAction = inputAction;
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = action;
return this;
}
/**
* Builds the final {@link QCSlider}.
*/
public QCSlider build() {
return new QCSlider(mMin, mMax, mValue, mIsEnabled, mIsClickableWhileDisabled,
mInputAction, mDisabledClickAction);
}
}
}

View File

@@ -0,0 +1,222 @@
/*
* Copyright (C) 2021 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.car.qc;
import android.app.PendingIntent;
import android.graphics.drawable.Icon;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Quick Control Tile Element
* ------------
* | -------- |
* | | Icon | |
* | -------- |
* | Subtitle |
* ------------
*/
public class QCTile extends QCItem {
private final boolean mIsChecked;
private final boolean mIsAvailable;
private final String mSubtitle;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
public QCTile(boolean isChecked, boolean isEnabled, boolean isAvailable,
boolean isClickableWhileDisabled, @Nullable String subtitle, @Nullable Icon icon,
@Nullable PendingIntent action, @Nullable PendingIntent disabledClickAction) {
super(QC_TYPE_TILE, isEnabled, isClickableWhileDisabled);
mIsChecked = isChecked;
mIsAvailable = isAvailable;
mSubtitle = subtitle;
mIcon = icon;
mAction = action;
mDisabledClickAction = disabledClickAction;
}
public QCTile(@NonNull Parcel in) {
super(in);
mIsChecked = in.readBoolean();
mIsAvailable = in.readBoolean();
mSubtitle = in.readString();
boolean hasIcon = in.readBoolean();
if (hasIcon) {
mIcon = Icon.CREATOR.createFromParcel(in);
}
boolean hasAction = in.readBoolean();
if (hasAction) {
mAction = PendingIntent.CREATOR.createFromParcel(in);
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = PendingIntent.CREATOR.createFromParcel(in);
}
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeBoolean(mIsChecked);
dest.writeBoolean(mIsAvailable);
dest.writeString(mSubtitle);
boolean hasIcon = mIcon != null;
dest.writeBoolean(hasIcon);
if (hasIcon) {
mIcon.writeToParcel(dest, flags);
}
boolean hasAction = mAction != null;
dest.writeBoolean(hasAction);
if (hasAction) {
mAction.writeToParcel(dest, flags);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
public boolean isChecked() {
return mIsChecked;
}
public boolean isAvailable() {
return mIsAvailable;
}
@Nullable
public String getSubtitle() {
return mSubtitle;
}
@Nullable
public Icon getIcon() {
return mIcon;
}
public static Creator<QCTile> CREATOR = new Creator<QCTile>() {
@Override
public QCTile createFromParcel(Parcel source) {
return new QCTile(source);
}
@Override
public QCTile[] newArray(int size) {
return new QCTile[size];
}
};
/**
* Builder for {@link QCTile}.
*/
public static class Builder {
private boolean mIsChecked;
private boolean mIsEnabled = true;
private boolean mIsAvailable = true;
private boolean mIsClickableWhileDisabled = false;
private String mSubtitle;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
/**
* Sets whether or not the tile should be checked.
*/
public Builder setChecked(boolean checked) {
mIsChecked = checked;
return this;
}
/**
* Sets whether or not the tile should be enabled.
*/
public Builder setEnabled(boolean enabled) {
mIsEnabled = enabled;
return this;
}
/**
* Sets whether or not the action item is available.
*/
public Builder setAvailable(boolean available) {
mIsAvailable = available;
return this;
}
/**
* Sets whether or not a tile should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
return this;
}
/**
* Sets the tile's subtitle.
*/
public Builder setSubtitle(@Nullable String subtitle) {
mSubtitle = subtitle;
return this;
}
/**
* Sets the tile's icon.
*/
public Builder setIcon(@Nullable Icon icon) {
mIcon = icon;
return this;
}
/**
* Sets the PendingIntent to be sent when the tile is clicked.
*/
public Builder setAction(@Nullable PendingIntent action) {
mAction = action;
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = action;
return this;
}
/**
* Builds the final {@link QCTile}.
*/
public QCTile build() {
return new QCTile(mIsChecked, mIsEnabled, mIsAvailable, mIsClickableWhileDisabled,
mSubtitle, mIcon, mAction, mDisabledClickAction);
}
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (C) 2021 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.car.qc.controller;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.lifecycle.Observer;
import com.android.car.qc.QCItem;
import java.util.ArrayList;
import java.util.List;
/**
* Base controller class for Quick Controls.
*/
public abstract class BaseQCController implements QCItemCallback {
protected final Context mContext;
protected final List<Observer<QCItem>> mObservers = new ArrayList<>();
protected boolean mShouldListen = false;
protected boolean mWasListening = false;
protected QCItem mQCItem;
public BaseQCController(Context context) {
mContext = context;
}
/**
* Update whether or not the controller should be listening to updates from the provider.
*/
public void listen(boolean shouldListen) {
mShouldListen = shouldListen;
updateListening();
}
/**
* Add a QCItem observer to the controller.
*/
@UiThread
public void addObserver(Observer<QCItem> observer) {
mObservers.add(observer);
updateListening();
}
/**
* Remove a QCItem observer from the controller.
*/
@UiThread
public void removeObserver(Observer<QCItem> observer) {
mObservers.remove(observer);
updateListening();
}
@UiThread
@Override
public void onQCItemUpdated(@Nullable QCItem item) {
mQCItem = item;
mObservers.forEach(o -> o.onChanged(mQCItem));
}
/**
* Destroy the controller. This should be called when the controller is no longer needed so
* the listeners can be cleaned up.
*/
public void destroy() {
mShouldListen = false;
mObservers.clear();
updateListening();
}
/**
* Perform a single retrieval from the provider (without subscribing to live updates).
*/
public abstract void bind();
/**
* Subclasses must override this method to handle a listening update.
*/
protected abstract void updateListening();
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2021 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.car.qc.controller;
import android.content.Context;
import com.android.car.qc.provider.BaseLocalQCProvider;
/**
* Controller for binding to local quick control providers.
*/
public class LocalQCController extends BaseQCController {
private final BaseLocalQCProvider mProvider;
private final BaseLocalQCProvider.Notifier mProviderNotifier =
new BaseLocalQCProvider.Notifier() {
@Override
public void notifyUpdate() {
if (mShouldListen && !mObservers.isEmpty()) {
onQCItemUpdated(mProvider.getQCItem());
}
}
};
public LocalQCController(Context context, BaseLocalQCProvider provider) {
super(context);
mProvider = provider;
mProvider.setNotifier(mProviderNotifier);
mQCItem = mProvider.getQCItem();
}
@Override
public void bind() {
onQCItemUpdated(mProvider.getQCItem());
}
@Override
protected void updateListening() {
boolean listen = mShouldListen && !mObservers.isEmpty();
if (mWasListening != listen) {
mWasListening = listen;
mProvider.shouldListen(listen);
if (listen) {
mQCItem = mProvider.getQCItem();
onQCItemUpdated(mQCItem);
}
}
}
@Override
public void destroy() {
super.destroy();
mProvider.onDestroy();
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (C) 2021 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.car.qc.controller;
import androidx.annotation.Nullable;
import com.android.car.qc.QCItem;
/**
* Callback to be executed when a QCItem changes.
*/
public interface QCItemCallback {
/**
* Called when QCItem is updated.
*
* @param item The updated QCItem.
*/
void onQCItemUpdated(@Nullable QCItem item);
}

View File

@@ -0,0 +1,278 @@
/*
* Copyright (C) 2021 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.car.qc.controller;
import static com.android.car.qc.provider.BaseQCProvider.EXTRA_ITEM;
import static com.android.car.qc.provider.BaseQCProvider.EXTRA_URI;
import static com.android.car.qc.provider.BaseQCProvider.METHOD_BIND;
import static com.android.car.qc.provider.BaseQCProvider.METHOD_DESTROY;
import static com.android.car.qc.provider.BaseQCProvider.METHOD_SUBSCRIBE;
import static com.android.car.qc.provider.BaseQCProvider.METHOD_UNSUBSCRIBE;
import android.content.ContentProviderClient;
import android.content.Context;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerExecutor;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Parcelable;
import android.os.RemoteException;
import android.util.ArrayMap;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import com.android.car.qc.QCItem;
import java.util.concurrent.Executor;
/**
* Controller for binding to remote quick control providers.
*/
public class RemoteQCController extends BaseQCController {
private static final String TAG = "RemoteQCController";
private static final long PROVIDER_ANR_TIMEOUT = 3000L;
private final Uri mUri;
private final Executor mBackgroundExecutor;
private final HandlerThread mBackgroundHandlerThread;
private final ArrayMap<Pair<Uri, QCItemCallback>, QCObserver> mObserverLookup =
new ArrayMap<>();
public RemoteQCController(Context context, Uri uri) {
super(context);
mUri = uri;
mBackgroundHandlerThread = new HandlerThread(/* name= */ TAG + "HandlerThread");
mBackgroundHandlerThread.start();
mBackgroundExecutor = new HandlerExecutor(
new Handler(mBackgroundHandlerThread.getLooper()));
}
@VisibleForTesting
RemoteQCController(Context context, Uri uri, Executor backgroundExecutor) {
super(context);
mUri = uri;
mBackgroundHandlerThread = null;
mBackgroundExecutor = backgroundExecutor;
}
@Override
public void bind() {
mBackgroundExecutor.execute(this::updateQCItem);
}
@Override
protected void updateListening() {
boolean listen = mShouldListen && !mObservers.isEmpty();
mBackgroundExecutor.execute(() -> updateListeningBg(listen));
}
@Override
public void destroy() {
super.destroy();
if (mBackgroundHandlerThread != null) {
mBackgroundHandlerThread.quit();
}
try (ContentProviderClient client = getClient()) {
if (client == null) {
return;
}
Bundle b = new Bundle();
b.putParcelable(EXTRA_URI, mUri);
try {
client.call(METHOD_DESTROY, /* arg= */ null, b);
} catch (Exception e) {
Log.d(TAG, "Error destroying QCItem", e);
}
}
}
@WorkerThread
private void updateListeningBg(boolean isListening) {
if (mWasListening != isListening) {
mWasListening = isListening;
if (isListening) {
registerQCCallback(mContext.getMainExecutor(), /* callback= */ this);
// Update one-time on a different thread so that it can display in parallel
mBackgroundExecutor.execute(this::updateQCItem);
} else {
unregisterQCCallback(this);
}
}
}
@WorkerThread
private void updateQCItem() {
try {
QCItem item = getQCItem();
mContext.getMainExecutor().execute(() -> onQCItemUpdated(item));
} catch (Exception e) {
Log.d(TAG, "Error fetching QCItem", e);
}
}
private QCItem getQCItem() {
try (ContentProviderClient provider = getClient()) {
if (provider == null) {
return null;
}
Bundle extras = new Bundle();
extras.putParcelable(EXTRA_URI, mUri);
Bundle res = provider.call(METHOD_BIND, /* arg= */ null, extras);
if (res == null) {
return null;
}
res.setDefusable(true);
res.setClassLoader(QCItem.class.getClassLoader());
Parcelable parcelable = res.getParcelable(EXTRA_ITEM);
if (parcelable instanceof QCItem) {
return (QCItem) parcelable;
}
return null;
} catch (RemoteException e) {
Log.d(TAG, "Error binding QCItem", e);
return null;
}
}
private void subscribe() {
try (ContentProviderClient client = getClient()) {
if (client == null) {
return;
}
Bundle b = new Bundle();
b.putParcelable(EXTRA_URI, mUri);
try {
client.call(METHOD_SUBSCRIBE, /* arg= */ null, b);
} catch (Exception e) {
Log.d(TAG, "Error subscribing to QCItem", e);
}
}
}
private void unsubscribe() {
try (ContentProviderClient client = getClient()) {
if (client == null) {
return;
}
Bundle b = new Bundle();
b.putParcelable(EXTRA_URI, mUri);
try {
client.call(METHOD_UNSUBSCRIBE, /* arg= */ null, b);
} catch (Exception e) {
Log.d(TAG, "Error unsubscribing from QCItem", e);
}
}
}
@VisibleForTesting
ContentProviderClient getClient() {
ContentProviderClient client = mContext.getContentResolver()
.acquireContentProviderClient(mUri);
if (client == null) {
return null;
}
client.setDetectNotResponding(PROVIDER_ANR_TIMEOUT);
return client;
}
private void registerQCCallback(@NonNull Executor executor, @NonNull QCItemCallback callback) {
getObserver(callback, new QCObserver(mUri, executor, callback)).startObserving();
}
private void unregisterQCCallback(@NonNull QCItemCallback callback) {
synchronized (mObserverLookup) {
QCObserver observer = mObserverLookup.remove(new Pair<>(mUri, callback));
if (observer != null) {
observer.stopObserving();
}
}
}
private QCObserver getObserver(QCItemCallback callback, QCObserver observer) {
Pair<Uri, QCItemCallback> key = new Pair<>(mUri, callback);
synchronized (mObserverLookup) {
QCObserver oldObserver = mObserverLookup.put(key, observer);
if (oldObserver != null) {
oldObserver.stopObserving();
}
}
return observer;
}
private class QCObserver {
private final Uri mUri;
private final Executor mExecutor;
private final QCItemCallback mCallback;
private boolean mIsSubscribed;
private final Runnable mUpdateItem = new Runnable() {
@Override
public void run() {
trySubscribe();
QCItem item = getQCItem();
mExecutor.execute(() -> mCallback.onQCItemUpdated(item));
}
};
private final ContentObserver mObserver = new ContentObserver(
new Handler(Looper.getMainLooper())) {
@Override
public void onChange(boolean selfChange) {
android.os.AsyncTask.execute(mUpdateItem);
}
};
QCObserver(Uri uri, Executor executor, QCItemCallback callback) {
mUri = uri;
mExecutor = executor;
mCallback = callback;
}
void startObserving() {
ContentProviderClient provider =
mContext.getContentResolver().acquireContentProviderClient(mUri);
if (provider != null) {
provider.close();
mContext.getContentResolver().registerContentObserver(
mUri, /* notifyForDescendants= */ true, mObserver);
trySubscribe();
}
}
void trySubscribe() {
if (!mIsSubscribed) {
subscribe();
mIsSubscribed = true;
}
}
void stopObserving() {
mContext.getContentResolver().unregisterContentObserver(mObserver);
if (mIsSubscribed) {
unsubscribe();
mIsSubscribed = false;
}
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (C) 2021 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.car.qc.provider;
import android.content.Context;
import com.android.car.qc.QCItem;
/**
* Base class for local Quick Control providers.
*/
public abstract class BaseLocalQCProvider {
/**
* Callback to be executed when the QCItem updates.
*/
public interface Notifier {
/**
* Called when the QCItem has been updated.
*/
default void notifyUpdate() {
}
}
private Notifier mNotifier;
private boolean mIsListening;
protected final Context mContext;
public BaseLocalQCProvider(Context context) {
mContext = context;
}
/**
* Set the notifier that should be called when the QCItem updates.
*/
public void setNotifier(Notifier notifier) {
mNotifier = notifier;
}
/**
* Update whether or not the provider should be listening for live updates.
*/
public void shouldListen(boolean listen) {
if (mIsListening == listen) {
return;
}
mIsListening = listen;
if (listen) {
onSubscribed();
} else {
onUnsubscribed();
}
}
/**
* Method to create and return a {@link QCItem}.
*/
public abstract QCItem getQCItem();
/**
* Called to inform the provider that it has been subscribed to.
*/
protected void onSubscribed() {
}
/**
* Called to inform the provider that it has been unsubscribed from.
*/
protected void onUnsubscribed() {
}
/**
* Called to inform the provider that it is being destroyed.
*/
public void onDestroy() {
}
protected void notifyChange() {
if (mNotifier != null) {
mNotifier.notifyUpdate();
}
}
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright (C) 2021 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.car.qc.provider;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.os.StrictMode;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.car.qc.QCItem;
import java.util.Set;
/**
* Base Quick Controls provider implementation.
*/
public abstract class BaseQCProvider extends ContentProvider {
public static final String METHOD_BIND = "QC_METHOD_BIND";
public static final String METHOD_SUBSCRIBE = "QC_METHOD_SUBSCRIBE";
public static final String METHOD_UNSUBSCRIBE = "QC_METHOD_UNSUBSCRIBE";
public static final String METHOD_DESTROY = "QC_METHOD_DESTROY";
public static final String EXTRA_URI = "QC_EXTRA_URI";
public static final String EXTRA_ITEM = "QC_EXTRA_ITEM";
private static final String TAG = "BaseQCProvider";
private static final long QC_ANR_TIMEOUT = 3000L;
private static final Handler MAIN_THREAD_HANDLER = new Handler(Looper.getMainLooper());
private String mCallbackMethod;
private final Runnable mAnr = () -> {
Process.sendSignal(Process.myPid(), Process.SIGNAL_QUIT);
Log.e(TAG, "Timed out while handling QC method " + mCallbackMethod);
};
@Override
public boolean onCreate() {
return true;
}
@Override
public Bundle call(String method, String arg, Bundle extras) {
enforceCallingPermissions();
Uri uri = getUriWithoutUserId(validateIncomingUriOrNull(
extras.getParcelable(EXTRA_URI)));
switch(method) {
case METHOD_BIND:
QCItem item = handleBind(uri);
Bundle b = new Bundle();
b.putParcelable(EXTRA_ITEM, item);
return b;
case METHOD_SUBSCRIBE:
handleSubscribe(uri);
break;
case METHOD_UNSUBSCRIBE:
handleUnsubscribe(uri);
break;
case METHOD_DESTROY:
handleDestroy(uri);
break;
}
return super.call(method, arg, extras);
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
return null;
}
@Override
public String getType(Uri uri) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
return 0;
}
/**
* Method to create and return a {@link QCItem}.
*
* onBind is expected to return as quickly as possible. Therefore, no network or other IO
* will be allowed. Any loading that needs to be done should happen in the background and
* should then notify the content resolver of the change when ready to provide the
* complete data in onBind.
*/
@Nullable
protected QCItem onBind(@NonNull Uri uri) {
return null;
}
/**
* Called to inform an app that an item has been subscribed to.
*
* Subscribing is a way that a host can notify apps of which QCItems they would like to
* receive updates for. The providing apps are expected to keep the content up to date
* and notify of change via the content resolver.
*/
protected void onSubscribed(@NonNull Uri uri) {
}
/**
* Called to inform an app that an item has been unsubscribed from.
*
* This is used to notify providing apps that a host is no longer listening
* to updates, so any background processes and/or listeners should be removed.
*/
protected void onUnsubscribed(@NonNull Uri uri) {
}
/**
* Called to inform an app that an item is being destroyed.
*
* This is used to notify providing apps that a host is no longer going to use this QCItem
* instance, so the relevant elements should be cleaned up.
*/
protected void onDestroy(@NonNull Uri uri) {
}
/**
* Returns a Set of packages that are allowed to call this provider.
*/
@NonNull
protected abstract Set<String> getAllowlistedPackages();
private QCItem handleBind(Uri uri) {
mCallbackMethod = "handleBind";
MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT);
try {
return onBindStrict(uri);
} finally {
MAIN_THREAD_HANDLER.removeCallbacks(mAnr);
}
}
private QCItem onBindStrict(@NonNull Uri uri) {
StrictMode.ThreadPolicy oldPolicy = StrictMode.getThreadPolicy();
try {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectAll()
// TODO(268275789): Revert back to penaltyDeath and ensure it works in
// presubmit
.penaltyLog()
.build());
return onBind(uri);
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
}
private void handleSubscribe(@NonNull Uri uri) {
mCallbackMethod = "handleSubscribe";
MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT);
try {
onSubscribed(uri);
} finally {
MAIN_THREAD_HANDLER.removeCallbacks(mAnr);
}
}
private void handleUnsubscribe(@NonNull Uri uri) {
mCallbackMethod = "handleUnsubscribe";
MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT);
try {
onUnsubscribed(uri);
} finally {
MAIN_THREAD_HANDLER.removeCallbacks(mAnr);
}
}
private void handleDestroy(@NonNull Uri uri) {
mCallbackMethod = "handleDestroy";
MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT);
try {
onDestroy(uri);
} finally {
MAIN_THREAD_HANDLER.removeCallbacks(mAnr);
}
}
private Uri validateIncomingUriOrNull(Uri uri) {
if (uri == null) {
throw new IllegalArgumentException("Uri cannot be null");
}
return validateIncomingUri(uri);
}
private void enforceCallingPermissions() {
String callingPackage = getCallingPackage();
if (callingPackage == null) {
throw new IllegalArgumentException("Calling package cannot be null");
}
if (!getAllowlistedPackages().contains(callingPackage)) {
throw new SecurityException(
String.format("%s is not permitted to access provider: %s", callingPackage,
getClass().getName()));
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2021 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.car.qc.view;
import static com.android.car.qc.view.QCView.QCActionListener;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.LinearLayout;
import androidx.lifecycle.Observer;
import com.android.car.qc.QCItem;
import com.android.car.qc.QCList;
/**
* Quick Controls view for {@link QCList} instances.
*/
public class QCListView extends LinearLayout implements Observer<QCItem> {
private QCActionListener mActionListener;
public QCListView(Context context) {
super(context);
init();
}
public QCListView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public QCListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public QCListView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
private void init() {
setOrientation(VERTICAL);
}
/**
* Set the view's {@link QCActionListener}. This listener will propagate to all QCRows.
*/
public void setActionListener(QCActionListener listener) {
mActionListener = listener;
for (int i = 0; i < getChildCount(); i++) {
QCRowView view = (QCRowView) getChildAt(i);
view.setActionListener(mActionListener);
}
}
@Override
public void onChanged(QCItem qcItem) {
if (qcItem == null) {
removeAllViews();
return;
}
if (!qcItem.getType().equals(QCItem.QC_TYPE_LIST)) {
throw new IllegalArgumentException("Expected QCList type for QCListView but got "
+ qcItem.getType());
}
QCList qcList = (QCList) qcItem;
int rowCount = qcList.getRows().size();
for (int i = 0; i < rowCount; i++) {
if (getChildAt(i) != null) {
QCRowView view = (QCRowView) getChildAt(i);
view.setRow(qcList.getRows().get(i));
view.setActionListener(mActionListener);
} else {
QCRowView view = new QCRowView(getContext());
view.setRow(qcList.getRows().get(i));
view.setActionListener(mActionListener);
addView(view);
}
}
if (getChildCount() > rowCount) {
// remove extra rows
removeViews(rowCount, getChildCount() - rowCount);
}
}
}

View File

@@ -0,0 +1,538 @@
/*
* Copyright (C) 2021 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.car.qc.view;
import static com.android.car.qc.QCItem.QC_ACTION_SLIDER_VALUE;
import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE;
import static com.android.car.qc.QCItem.QC_TYPE_ACTION_SWITCH;
import static com.android.car.qc.view.QCView.QCActionListener;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.text.BidiFormatter;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.SeekBar;
import android.widget.Switch;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.car.qc.QCActionItem;
import com.android.car.qc.QCItem;
import com.android.car.qc.QCRow;
import com.android.car.qc.QCSlider;
import com.android.car.qc.R;
import com.android.car.ui.utils.CarUiUtils;
import com.android.car.ui.utils.DirectManipulationHelper;
import com.android.car.ui.uxr.DrawableStateToggleButton;
/**
* Quick Controls view for {@link QCRow} instances.
*/
public class QCRowView extends FrameLayout {
private static final String TAG = "QCRowView";
private LayoutInflater mLayoutInflater;
private BidiFormatter mBidiFormatter;
private View mContentView;
private TextView mTitle;
private TextView mSubtitle;
private ImageView mStartIcon;
@ColorInt
private int mStartIconTint;
private LinearLayout mStartItemsContainer;
private LinearLayout mEndItemsContainer;
private LinearLayout mSeekBarContainer;
@Nullable
private QCSlider mQCSlider;
private QCSeekBarView mSeekBar;
private QCActionListener mActionListener;
private boolean mInDirectManipulationMode;
private QCSeekbarChangeListener mSeekbarChangeListener;
private final View.OnKeyListener mSeekBarKeyListener = new View.OnKeyListener() {
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (mSeekBar == null || (!mSeekBar.isEnabled()
&& !mSeekBar.isClickableWhileDisabled())) {
return false;
}
// Consume nudge events in direct manipulation mode.
if (mInDirectManipulationMode
&& (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|| keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
|| keyCode == KeyEvent.KEYCODE_DPAD_UP
|| keyCode == KeyEvent.KEYCODE_DPAD_DOWN)) {
return true;
}
// Handle events to enter or exit direct manipulation mode.
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (mQCSlider != null) {
if (mQCSlider.isEnabled()) {
setInDirectManipulationMode(v, mSeekBar, !mInDirectManipulationMode);
} else {
fireAction(mQCSlider, new Intent());
}
}
}
return true;
}
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (mInDirectManipulationMode) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
setInDirectManipulationMode(v, mSeekBar, false);
}
return true;
}
}
// Don't propagate confirm keys to the SeekBar to prevent a ripple effect on the thumb.
if (KeyEvent.isConfirmKey(keyCode)) {
return false;
}
if (event.getAction() == KeyEvent.ACTION_DOWN) {
return mSeekBar.onKeyDown(keyCode, event);
} else {
return mSeekBar.onKeyUp(keyCode, event);
}
}
};
private final View.OnFocusChangeListener mSeekBarFocusChangeListener =
(v, hasFocus) -> {
if (!hasFocus && mInDirectManipulationMode && mSeekBar != null) {
setInDirectManipulationMode(v, mSeekBar, false);
}
};
private final View.OnGenericMotionListener mSeekBarScrollListener =
(v, event) -> {
if (!mInDirectManipulationMode || mSeekBar == null) {
return false;
}
int adjustment = Math.round(event.getAxisValue(MotionEvent.AXIS_SCROLL));
if (adjustment == 0) {
return false;
}
int count = Math.abs(adjustment);
int keyCode =
adjustment < 0 ? KeyEvent.KEYCODE_DPAD_LEFT : KeyEvent.KEYCODE_DPAD_RIGHT;
KeyEvent downEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
KeyEvent.ACTION_DOWN, keyCode, /* repeat= */ 0);
KeyEvent upEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
KeyEvent.ACTION_UP, keyCode, /* repeat= */ 0);
for (int i = 0; i < count; i++) {
mSeekBar.onKeyDown(keyCode, downEvent);
mSeekBar.onKeyUp(keyCode, upEvent);
}
return true;
};
QCRowView(Context context) {
super(context);
init(context);
}
QCRowView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
QCRowView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
QCRowView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context);
}
private void init(Context context) {
mLayoutInflater = LayoutInflater.from(context);
mBidiFormatter = BidiFormatter.getInstance();
mLayoutInflater.inflate(R.layout.qc_row_view, /* root= */ this);
mContentView = findViewById(R.id.qc_row_content);
mTitle = findViewById(R.id.qc_title);
mSubtitle = findViewById(R.id.qc_summary);
mStartIcon = findViewById(R.id.qc_icon);
mStartItemsContainer = findViewById(R.id.qc_row_start_items);
mEndItemsContainer = findViewById(R.id.qc_row_end_items);
mSeekBarContainer = findViewById(R.id.qc_seekbar_wrapper);
mSeekBar = findViewById(R.id.qc_seekbar);
}
void setActionListener(QCActionListener listener) {
mActionListener = listener;
}
void setRow(QCRow row) {
if (row == null) {
setVisibility(GONE);
return;
}
setVisibility(VISIBLE);
CarUiUtils.makeAllViewsEnabled(mContentView, row.isEnabled());
if (!row.isEnabled()) {
if (row.isClickableWhileDisabled() && (row.getDisabledClickAction() != null
|| row.getDisabledClickActionHandler() != null)) {
mContentView.setOnClickListener(v -> {
fireAction(row, /* intent= */ null);
});
}
} else if (row.getPrimaryAction() != null || row.getActionHandler() != null) {
mContentView.setOnClickListener(v -> {
fireAction(row, /* intent= */ null);
});
}
if (!TextUtils.isEmpty(row.getTitle())) {
mTitle.setVisibility(VISIBLE);
mTitle.setText(
mBidiFormatter.unicodeWrap(row.getTitle(), TextDirectionHeuristics.LOCALE));
} else {
mTitle.setVisibility(GONE);
}
if (!TextUtils.isEmpty(row.getSubtitle())) {
mSubtitle.setVisibility(VISIBLE);
mSubtitle.setText(
mBidiFormatter.unicodeWrap(row.getSubtitle(), TextDirectionHeuristics.LOCALE));
} else {
mSubtitle.setVisibility(GONE);
}
if (row.getStartIcon() != null) {
mStartIcon.setVisibility(VISIBLE);
Drawable drawable = row.getStartIcon().loadDrawable(getContext());
if (drawable != null && row.isStartIconTintable()) {
if (mStartIconTint == 0) {
mStartIconTint = getContext().getColor(R.color.qc_start_icon_color);
}
drawable.setTint(mStartIconTint);
}
mStartIcon.setImageDrawable(drawable);
} else {
mStartIcon.setImageDrawable(null);
mStartIcon.setVisibility(GONE);
}
QCSlider slider = row.getSlider();
if (slider != null) {
mSeekBarContainer.setVisibility(View.VISIBLE);
initSlider(slider);
} else {
mSeekBarContainer.setVisibility(View.GONE);
mQCSlider = null;
}
int startItemCount = row.getStartItems().size();
for (int i = 0; i < startItemCount; i++) {
QCActionItem action = row.getStartItems().get(i);
initActionItem(mStartItemsContainer, mStartItemsContainer.getChildAt(i), action);
}
if (mStartItemsContainer.getChildCount() > startItemCount) {
// remove extra items
mStartItemsContainer.removeViews(startItemCount,
mStartItemsContainer.getChildCount() - startItemCount);
}
if (startItemCount == 0) {
mStartItemsContainer.setVisibility(View.GONE);
} else {
mStartItemsContainer.setVisibility(View.VISIBLE);
}
int endItemCount = row.getEndItems().size();
for (int i = 0; i < endItemCount; i++) {
QCActionItem action = row.getEndItems().get(i);
initActionItem(mEndItemsContainer, mEndItemsContainer.getChildAt(i), action);
}
if (mEndItemsContainer.getChildCount() > endItemCount) {
// remove extra items
mEndItemsContainer.removeViews(endItemCount,
mEndItemsContainer.getChildCount() - endItemCount);
}
if (endItemCount == 0) {
mEndItemsContainer.setVisibility(View.GONE);
} else {
mEndItemsContainer.setVisibility(View.VISIBLE);
}
}
private void initActionItem(@NonNull ViewGroup root, @Nullable View actionView,
@NonNull QCActionItem action) {
if (action.getType().equals(QC_TYPE_ACTION_SWITCH)) {
initSwitchView(action, root, actionView);
} else {
initToggleView(action, root, actionView);
}
}
private void initSwitchView(QCActionItem action, ViewGroup root, View actionView) {
Switch switchView = actionView == null ? null : actionView.findViewById(
android.R.id.switch_widget);
if (switchView == null) {
actionView = createActionView(root, actionView, R.layout.qc_action_switch);
switchView = actionView.requireViewById(android.R.id.switch_widget);
}
CarUiUtils.makeAllViewsEnabled(switchView, action.isEnabled());
boolean shouldEnableView =
(action.isEnabled() || action.isClickableWhileDisabled()) && action.isAvailable()
&& action.isClickable();
switchView.setOnCheckedChangeListener(null);
switchView.setEnabled(shouldEnableView);
switchView.setChecked(action.isChecked());
switchView.setContentDescription(action.getContentDescription());
switchView.setOnTouchListener((v, event) -> {
if (!action.isEnabled()) {
if (event.getActionMasked() == MotionEvent.ACTION_UP) {
fireAction(action, new Intent());
}
return true;
}
return false;
});
switchView.setOnCheckedChangeListener(
(buttonView, isChecked) -> {
Intent intent = new Intent();
intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
fireAction(action, intent);
});
}
private void initToggleView(QCActionItem action, ViewGroup root, View actionView) {
DrawableStateToggleButton tmpToggleButton =
actionView == null ? null : actionView.findViewById(R.id.qc_toggle_button);
if (tmpToggleButton == null) {
actionView = createActionView(root, actionView, R.layout.qc_action_toggle);
tmpToggleButton = actionView.requireViewById(R.id.qc_toggle_button);
}
DrawableStateToggleButton toggleButton = tmpToggleButton; // must be effectively final
boolean shouldEnableView =
(action.isEnabled() || action.isClickableWhileDisabled()) && action.isAvailable()
&& action.isClickable();
toggleButton.setText(null);
toggleButton.setTextOn(null);
toggleButton.setTextOff(null);
toggleButton.setOnCheckedChangeListener(null);
Drawable icon = QCViewUtils.getToggleIcon(mContext, action.getIcon(), action.isAvailable());
toggleButton.setContentDescription(action.getContentDescription());
toggleButton.setButtonDrawable(icon);
toggleButton.setChecked(action.isChecked());
toggleButton.setEnabled(shouldEnableView);
setToggleButtonDrawableState(toggleButton, action.isEnabled(), action.isAvailable());
toggleButton.setOnTouchListener((v, event) -> {
if (!action.isEnabled()) {
if (event.getActionMasked() == MotionEvent.ACTION_UP) {
fireAction(action, new Intent());
}
return true;
}
return false;
});
toggleButton.setOnCheckedChangeListener(
(buttonView, isChecked) -> {
Intent intent = new Intent();
intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
fireAction(action, intent);
});
}
private void setToggleButtonDrawableState(DrawableStateToggleButton view,
boolean enabled, boolean available) {
int[] statesToAdd = null;
int[] statesToRemove = null;
if (enabled) {
if (!available) {
statesToAdd =
new int[]{android.R.attr.state_enabled, R.attr.state_toggle_unavailable};
} else {
statesToAdd = new int[]{android.R.attr.state_enabled};
statesToRemove = new int[]{R.attr.state_toggle_unavailable};
}
} else {
if (available) {
statesToRemove =
new int[]{android.R.attr.state_enabled, R.attr.state_toggle_unavailable};
} else {
statesToAdd = new int[]{R.attr.state_toggle_unavailable};
statesToRemove = new int[]{android.R.attr.state_enabled};
}
}
CarUiUtils.applyDrawableStatesToAllViews(view, statesToAdd, statesToRemove);
}
@NonNull
private View createActionView(@NonNull ViewGroup root, @Nullable View actionView,
@LayoutRes int resId) {
if (actionView != null) {
// remove current action view
root.removeView(actionView);
}
actionView = mLayoutInflater.inflate(resId, root, /* attachToRoot= */ false);
root.addView(actionView);
return actionView;
}
private void initSlider(QCSlider slider) {
mQCSlider = slider;
CarUiUtils.makeAllViewsEnabled(mSeekBar, slider.isEnabled());
mSeekBar.setOnSeekBarChangeListener(null);
mSeekBar.setMin(slider.getMin());
mSeekBar.setMax(slider.getMax());
mSeekBar.setProgress(slider.getValue());
mSeekBar.setEnabled(slider.isEnabled());
mSeekBar.setClickableWhileDisabled(slider.isClickableWhileDisabled());
mSeekBar.setDisabledClickListener(seekBar -> fireAction(slider, new Intent()));
if (!slider.isEnabled() && mInDirectManipulationMode) {
setInDirectManipulationMode(mSeekBarContainer, mSeekBar, false);
}
if (mSeekbarChangeListener == null) {
mSeekbarChangeListener = new QCSeekbarChangeListener();
}
mSeekbarChangeListener.setSlider(slider);
mSeekBar.setOnSeekBarChangeListener(mSeekbarChangeListener);
// set up rotary support
mSeekBarContainer.setOnKeyListener(mSeekBarKeyListener);
mSeekBarContainer.setOnFocusChangeListener(mSeekBarFocusChangeListener);
mSeekBarContainer.setOnGenericMotionListener(mSeekBarScrollListener);
}
private void setInDirectManipulationMode(View view, SeekBar seekbar, boolean enable) {
mInDirectManipulationMode = enable;
DirectManipulationHelper.enableDirectManipulationMode(seekbar, enable);
view.setSelected(enable);
seekbar.setSelected(enable);
}
private void fireAction(QCItem item, Intent intent) {
if (!item.isEnabled()) {
if (item.getDisabledClickAction() != null) {
try {
item.getDisabledClickAction().send(getContext(), 0, intent);
if (mActionListener != null) {
mActionListener.onQCAction(item, item.getDisabledClickAction());
}
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Error sending intent", e);
}
} else if (item.getDisabledClickActionHandler() != null) {
item.getDisabledClickActionHandler().onAction(item, getContext(), intent);
if (mActionListener != null) {
mActionListener.onQCAction(item, item.getDisabledClickActionHandler());
}
}
return;
}
if (item.getPrimaryAction() != null) {
try {
item.getPrimaryAction().send(getContext(), 0, intent);
if (mActionListener != null) {
mActionListener.onQCAction(item, item.getPrimaryAction());
}
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Error sending intent", e);
}
} else if (item.getActionHandler() != null) {
item.getActionHandler().onAction(item, getContext(), intent);
if (mActionListener != null) {
mActionListener.onQCAction(item, item.getActionHandler());
}
}
}
private class QCSeekbarChangeListener implements SeekBar.OnSeekBarChangeListener {
// Interval of updates (in ms) sent in response to seekbar moving.
private static final int SLIDER_UPDATE_INTERVAL = 200;
private final Handler mSliderUpdateHandler;
private QCSlider mSlider;
private int mCurrSliderValue;
private boolean mSliderUpdaterRunning;
private long mLastSentSliderUpdate;
private final Runnable mSliderUpdater = () -> {
sendSliderValue();
mSliderUpdaterRunning = false;
};
QCSeekbarChangeListener() {
mSliderUpdateHandler = new Handler();
}
void setSlider(QCSlider slider) {
mSlider = slider;
}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
mCurrSliderValue = progress;
long now = System.currentTimeMillis();
if (mLastSentSliderUpdate != 0
&& now - mLastSentSliderUpdate > SLIDER_UPDATE_INTERVAL) {
mSliderUpdaterRunning = false;
mSliderUpdateHandler.removeCallbacks(mSliderUpdater);
sendSliderValue();
} else if (!mSliderUpdaterRunning) {
mSliderUpdaterRunning = true;
mSliderUpdateHandler.postDelayed(mSliderUpdater, SLIDER_UPDATE_INTERVAL);
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
if (mSliderUpdaterRunning) {
mSliderUpdaterRunning = false;
mSliderUpdateHandler.removeCallbacks(mSliderUpdater);
}
mCurrSliderValue = seekBar.getProgress();
sendSliderValue();
}
private void sendSliderValue() {
if (mSlider == null) {
return;
}
mLastSentSliderUpdate = System.currentTimeMillis();
Intent intent = new Intent();
intent.putExtra(QC_ACTION_SLIDER_VALUE, mCurrSliderValue);
fireAction(mSlider, intent);
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.qc.view;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.SeekBar;
import androidx.annotation.Nullable;
import com.android.car.ui.uxr.DrawableStateSeekBar;
import java.util.function.Consumer;
/**
* A {@link SeekBar} specifically for Quick Controls that allows for a disabled click action
* to execute on {@link MotionEvent.ACTION_UP}.
*/
public class QCSeekBarView extends DrawableStateSeekBar {
private boolean mClickableWhileDisabled;
private Consumer<SeekBar> mDisabledClickListener;
public QCSeekBarView(Context context) {
super(context);
}
public QCSeekBarView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public QCSeekBarView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public QCSeekBarView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// AbsSeekBar will ignore all touch events if not enabled. If this SeekBar should be
// clickable while disabled, the touch event will be handled here.
if (!isEnabled() && mClickableWhileDisabled) {
if (event.getAction() == MotionEvent.ACTION_UP && mDisabledClickListener != null) {
mDisabledClickListener.accept(this);
}
return true;
}
return super.onTouchEvent(event);
}
public void setClickableWhileDisabled(boolean clickable) {
mClickableWhileDisabled = clickable;
}
public void setDisabledClickListener(@Nullable Consumer<SeekBar> disabledClickListener) {
mDisabledClickListener = disabledClickListener;
}
public boolean isClickableWhileDisabled() {
return mClickableWhileDisabled;
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright (C) 2021 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.car.qc.view;
import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE;
import static com.android.car.qc.view.QCView.QCActionListener;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.lifecycle.Observer;
import com.android.car.qc.QCItem;
import com.android.car.qc.QCTile;
import com.android.car.qc.R;
import com.android.car.ui.utils.CarUiUtils;
import com.android.car.ui.uxr.DrawableStateToggleButton;
/**
* Quick Controls view for {@link QCTile} instances.
*/
public class QCTileView extends FrameLayout implements Observer<QCItem> {
private static final String TAG = "QCTileView";
private View mTileWrapper;
private DrawableStateToggleButton mToggleButton;
private TextView mSubtitle;
private QCActionListener mActionListener;
public QCTileView(Context context) {
super(context);
init(context);
}
public QCTileView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public QCTileView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
public QCTileView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context);
}
/**
* Set the tile's {@link QCActionListener}.
*/
public void setActionListener(QCActionListener listener) {
mActionListener = listener;
}
private void init(Context context) {
View.inflate(context, R.layout.qc_tile_view, /* root= */ this);
mTileWrapper = findViewById(R.id.qc_tile_wrapper);
mToggleButton = findViewById(R.id.qc_tile_toggle_button);
mSubtitle = findViewById(android.R.id.summary);
mToggleButton.setText(null);
mToggleButton.setTextOn(null);
mToggleButton.setTextOff(null);
}
@Override
public void onChanged(QCItem qcItem) {
if (qcItem == null) {
removeAllViews();
return;
}
if (!qcItem.getType().equals(QCItem.QC_TYPE_TILE)) {
throw new IllegalArgumentException("Expected QCTile type for QCTileView but got "
+ qcItem.getType());
}
QCTile qcTile = (QCTile) qcItem;
mSubtitle.setText(qcTile.getSubtitle());
CarUiUtils.makeAllViewsEnabled(mToggleButton, qcTile.isEnabled());
mToggleButton.setOnCheckedChangeListener(null);
mToggleButton.setChecked(qcTile.isChecked());
mToggleButton.setEnabled(qcTile.isEnabled() || qcTile.isClickableWhileDisabled());
mTileWrapper.setEnabled(
(qcTile.isEnabled() || qcTile.isClickableWhileDisabled()) && qcTile.isAvailable());
mTileWrapper.setOnClickListener(v -> {
if (!qcTile.isEnabled()) {
if (qcTile.getDisabledClickAction() != null) {
try {
qcTile.getDisabledClickAction().send(getContext(), 0, new Intent());
if (mActionListener != null) {
mActionListener.onQCAction(qcTile, qcTile.getDisabledClickAction());
}
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Error sending intent", e);
}
} else if (qcTile.getDisabledClickActionHandler() != null) {
qcTile.getDisabledClickActionHandler().onAction(qcTile, getContext(),
new Intent());
if (mActionListener != null) {
mActionListener.onQCAction(qcTile, qcTile.getDisabledClickActionHandler());
}
}
return;
}
mToggleButton.toggle();
});
Drawable icon = QCViewUtils.getToggleIcon(mContext, qcTile.getIcon(), qcTile.isAvailable());
mToggleButton.setButtonDrawable(icon);
mToggleButton.setOnCheckedChangeListener(
(buttonView, isChecked) -> {
Intent intent = new Intent();
intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
if (qcTile.getPrimaryAction() != null) {
try {
qcTile.getPrimaryAction().send(getContext(), 0, intent);
if (mActionListener != null) {
mActionListener.onQCAction(qcTile, qcTile.getPrimaryAction());
}
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Error sending intent", e);
}
} else if (qcTile.getActionHandler() != null) {
qcTile.getActionHandler().onAction(qcTile, getContext(), intent);
if (mActionListener != null) {
mActionListener.onQCAction(qcTile, qcTile.getActionHandler());
}
}
});
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (C) 2021 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.car.qc.view;
import android.content.Context;
import android.util.AttributeSet;
import android.view.Gravity;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.lifecycle.Observer;
import com.android.car.qc.QCItem;
/**
* Base Quick Controls View - supports {@link QCItem.QC_TYPE_TILE} and {@link QCItem.QC_TYPE_LIST}
*/
public class QCView extends FrameLayout implements Observer<QCItem> {
@QCItem.QCItemType
private String mType;
private Observer<QCItem> mChildObserver;
private QCActionListener mActionListener;
public QCView(Context context) {
super(context);
}
public QCView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public QCView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public QCView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
/**
* Set the view's {@link QCActionListener}. This listener will propagate to all sub-views.
*/
public void setActionListener(QCActionListener listener) {
mActionListener = listener;
if (mChildObserver instanceof QCTileView) {
((QCTileView) mChildObserver).setActionListener(mActionListener);
} else if (mChildObserver instanceof QCListView) {
((QCListView) mChildObserver).setActionListener(mActionListener);
}
}
@Override
public void onChanged(QCItem qcItem) {
if (qcItem == null) {
removeAllViews();
mChildObserver = null;
mType = null;
return;
}
if (!isValidQCItemType(qcItem)) {
throw new IllegalArgumentException("Expected QCTile or QCList type but got "
+ qcItem.getType());
}
if (qcItem.getType().equals(mType)) {
mChildObserver.onChanged(qcItem);
return;
}
removeAllViews();
mType = qcItem.getType();
if (mType.equals(QCItem.QC_TYPE_TILE)) {
QCTileView view = new QCTileView(getContext());
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT,
Gravity.CENTER_HORIZONTAL);
view.onChanged(qcItem);
view.setActionListener(mActionListener);
addView(view, params);
mChildObserver = view;
} else {
QCListView view = new QCListView(getContext());
view.onChanged(qcItem);
view.setActionListener(mActionListener);
addView(view);
mChildObserver = view;
}
}
private boolean isValidQCItemType(QCItem qcItem) {
String type = qcItem.getType();
return type.equals(QCItem.QC_TYPE_TILE) || type.equals(QCItem.QC_TYPE_LIST);
}
/**
* Listener to be called when an action occurs on a QCView.
*/
public interface QCActionListener {
/**
* Called when an interaction has occurred with an element in this view.
* @param item the specific item within the {@link QCItem} that was interacted with.
* @param action the action that was executed - is generally either a
* {@link android.app.PendingIntent} or {@link QCItem.ActionHandler}
*/
void onQCAction(@NonNull QCItem item, @NonNull Object action);
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright (C) 2021 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.car.qc.view;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.graphics.drawable.LayerDrawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.car.qc.R;
/**
* Utility class used by {@link QCTileView} and {@link QCRowView}
*/
public class QCViewUtils {
/**
* Create a return a Quick Control toggle icon - used for tiles and action toggles.
*/
public static Drawable getToggleIcon(@NonNull Context context, @Nullable Icon icon,
boolean available) {
Drawable defaultToggleBackground = context.getDrawable(R.drawable.qc_toggle_background);
Drawable unavailableToggleBackground = context.getDrawable(
R.drawable.qc_toggle_unavailable_background);
int toggleForegroundIconInset = context.getResources()
.getDimensionPixelSize(R.dimen.qc_toggle_foreground_icon_inset);
Drawable background = available
? defaultToggleBackground.getConstantState().newDrawable().mutate()
: unavailableToggleBackground.getConstantState().newDrawable().mutate();
if (icon == null) {
return background;
}
Drawable iconDrawable = icon.loadDrawable(context);
if (iconDrawable == null) {
return background;
}
if (!available) {
int unavailableToggleIconTint = context.getColor(R.color.qc_toggle_unavailable_color);
iconDrawable.setTint(unavailableToggleIconTint);
} else {
ColorStateList defaultToggleIconTint = context.getColorStateList(
R.color.qc_toggle_icon_fill_color);
iconDrawable.setTintList(defaultToggleIconTint);
}
Drawable[] layers = {background, iconDrawable};
LayerDrawable drawable = new LayerDrawable(layers);
drawable.setLayerInsetRelative(/* index= */ 1, toggleForegroundIconInset,
toggleForegroundIconInset, toggleForegroundIconInset,
toggleForegroundIconInset);
return drawable;
}
}