Add support for Tabs in Car App Library by adding TabTemplate and
other relevant classes .
Bug: 237306725
Test: ./gradlew :car:app:app:test
Relnote: "Adds TabTemplate for introducing Tabs to Car App Library"
Change-Id: Ied4d4a37158f1943e3859862515e4d707f9f18c7
diff --git a/car/app/app/api/public_plus_experimental_1.3.0-beta02.txt b/car/app/app/api/public_plus_experimental_1.3.0-beta02.txt
index 447d006..9908df5 100644
--- a/car/app/app/api/public_plus_experimental_1.3.0-beta02.txt
+++ b/car/app/app/api/public_plus_experimental_1.3.0-beta02.txt
@@ -1048,6 +1048,10 @@
method public androidx.car.app.model.OnClickDelegate getOnClickDelegate();
}
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public interface Content {
+ method public String getContentId();
+ }
+
@androidx.car.app.annotations.CarProtocol public final class DateTimeWithZone {
method public static androidx.car.app.model.DateTimeWithZone create(long, @IntRange(from=0xffff02e0, to=64800) int, String);
method public static androidx.car.app.model.DateTimeWithZone create(long, java.util.TimeZone);
@@ -1456,6 +1460,59 @@
method public androidx.car.app.model.ItemList getItemList();
}
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public final class Tab implements androidx.car.app.model.Content {
+ method public String getContentId();
+ method public androidx.car.app.model.CarIcon getIcon();
+ method public androidx.car.app.model.CarText getTitle();
+ method public boolean isActive();
+ method public androidx.car.app.model.Tab.Builder toBuilder();
+ }
+
+ public static final class Tab.Builder {
+ ctor public Tab.Builder();
+ method public androidx.car.app.model.Tab build();
+ method public androidx.car.app.model.Tab.Builder setActive(boolean);
+ method public androidx.car.app.model.Tab.Builder setContentId(String);
+ method public androidx.car.app.model.Tab.Builder setIcon(androidx.car.app.model.CarIcon);
+ method public androidx.car.app.model.Tab.Builder setTitle(CharSequence);
+ }
+
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public interface TabCallbackDelegate {
+ method public void sendTabSelected(String, androidx.car.app.OnDoneCallback);
+ }
+
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public class TabContents implements androidx.car.app.model.Content {
+ method public String getContentId();
+ method public androidx.car.app.model.Template getTemplate();
+ field public static final String CONTENT_ID = "TAB_CONTENTS_CONTENT_ID";
+ }
+
+ public static final class TabContents.Builder {
+ ctor public TabContents.Builder(androidx.car.app.model.Template);
+ method public androidx.car.app.model.TabContents build();
+ }
+
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public class TabTemplate implements androidx.car.app.model.Template {
+ method public androidx.car.app.model.Action getHeaderAction();
+ method public androidx.car.app.model.TabCallbackDelegate getTabCallbackDelegate();
+ method public androidx.car.app.model.TabContents getTabContents();
+ method public java.util.List<androidx.car.app.model.Tab!> getTabs();
+ method public boolean isLoading();
+ }
+
+ public static final class TabTemplate.Builder {
+ ctor public TabTemplate.Builder(androidx.car.app.model.TabTemplate.TabCallback);
+ method public androidx.car.app.model.TabTemplate.Builder addTab(androidx.car.app.model.Tab);
+ method public androidx.car.app.model.TabTemplate build();
+ method public androidx.car.app.model.TabTemplate.Builder setHeaderAction(androidx.car.app.model.Action);
+ method public androidx.car.app.model.TabTemplate.Builder setLoading(boolean);
+ method public androidx.car.app.model.TabTemplate.Builder setTabContents(androidx.car.app.model.TabContents);
+ }
+
+ public static interface TabTemplate.TabCallback {
+ method public default void onTabSelected(String);
+ }
+
@androidx.car.app.annotations.CarProtocol public interface Template {
}
diff --git a/car/app/app/api/public_plus_experimental_current.txt b/car/app/app/api/public_plus_experimental_current.txt
index 447d006..9908df5 100644
--- a/car/app/app/api/public_plus_experimental_current.txt
+++ b/car/app/app/api/public_plus_experimental_current.txt
@@ -1048,6 +1048,10 @@
method public androidx.car.app.model.OnClickDelegate getOnClickDelegate();
}
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public interface Content {
+ method public String getContentId();
+ }
+
@androidx.car.app.annotations.CarProtocol public final class DateTimeWithZone {
method public static androidx.car.app.model.DateTimeWithZone create(long, @IntRange(from=0xffff02e0, to=64800) int, String);
method public static androidx.car.app.model.DateTimeWithZone create(long, java.util.TimeZone);
@@ -1456,6 +1460,59 @@
method public androidx.car.app.model.ItemList getItemList();
}
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public final class Tab implements androidx.car.app.model.Content {
+ method public String getContentId();
+ method public androidx.car.app.model.CarIcon getIcon();
+ method public androidx.car.app.model.CarText getTitle();
+ method public boolean isActive();
+ method public androidx.car.app.model.Tab.Builder toBuilder();
+ }
+
+ public static final class Tab.Builder {
+ ctor public Tab.Builder();
+ method public androidx.car.app.model.Tab build();
+ method public androidx.car.app.model.Tab.Builder setActive(boolean);
+ method public androidx.car.app.model.Tab.Builder setContentId(String);
+ method public androidx.car.app.model.Tab.Builder setIcon(androidx.car.app.model.CarIcon);
+ method public androidx.car.app.model.Tab.Builder setTitle(CharSequence);
+ }
+
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public interface TabCallbackDelegate {
+ method public void sendTabSelected(String, androidx.car.app.OnDoneCallback);
+ }
+
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public class TabContents implements androidx.car.app.model.Content {
+ method public String getContentId();
+ method public androidx.car.app.model.Template getTemplate();
+ field public static final String CONTENT_ID = "TAB_CONTENTS_CONTENT_ID";
+ }
+
+ public static final class TabContents.Builder {
+ ctor public TabContents.Builder(androidx.car.app.model.Template);
+ method public androidx.car.app.model.TabContents build();
+ }
+
+ @androidx.car.app.annotations.CarProtocol @androidx.car.app.annotations.ExperimentalCarApi @androidx.car.app.annotations.RequiresCarApi(6) public class TabTemplate implements androidx.car.app.model.Template {
+ method public androidx.car.app.model.Action getHeaderAction();
+ method public androidx.car.app.model.TabCallbackDelegate getTabCallbackDelegate();
+ method public androidx.car.app.model.TabContents getTabContents();
+ method public java.util.List<androidx.car.app.model.Tab!> getTabs();
+ method public boolean isLoading();
+ }
+
+ public static final class TabTemplate.Builder {
+ ctor public TabTemplate.Builder(androidx.car.app.model.TabTemplate.TabCallback);
+ method public androidx.car.app.model.TabTemplate.Builder addTab(androidx.car.app.model.Tab);
+ method public androidx.car.app.model.TabTemplate build();
+ method public androidx.car.app.model.TabTemplate.Builder setHeaderAction(androidx.car.app.model.Action);
+ method public androidx.car.app.model.TabTemplate.Builder setLoading(boolean);
+ method public androidx.car.app.model.TabTemplate.Builder setTabContents(androidx.car.app.model.TabContents);
+ }
+
+ public static interface TabTemplate.TabCallback {
+ method public default void onTabSelected(String);
+ }
+
@androidx.car.app.annotations.CarProtocol public interface Template {
}
diff --git a/car/app/app/src/main/aidl/androidx/car/app/model/ITabCallback.aidl b/car/app/app/src/main/aidl/androidx/car/app/model/ITabCallback.aidl
new file mode 100644
index 0000000..4887327
--- /dev/null
+++ b/car/app/app/src/main/aidl/androidx/car/app/model/ITabCallback.aidl
@@ -0,0 +1,25 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import androidx.car.app.IOnDoneCallback;
+
+/** @hide */
+oneway interface ITabCallback {
+ /** Will be triggered when a tab is selected */
+ void onTabSelected(String tabContentId, IOnDoneCallback callback) = 1;
+}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/Content.java b/car/app/app/src/main/java/androidx/car/app/model/Content.java
new file mode 100644
index 0000000..5023c429
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/Content.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import androidx.annotation.NonNull;
+import androidx.car.app.annotations.CarProtocol;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+
+/** Interface implemented by models that can be invalidated and refreshed individually. */
+@CarProtocol
+@ExperimentalCarApi
+@RequiresCarApi(6)
+public interface Content {
+
+ /**
+ * Returns the associated content ID for a component. This ID will be used to trigger
+ * invalidation and refresh of the component, and can not change.
+ *
+ */
+ @NonNull
+ String getContentId();
+}
+
diff --git a/car/app/app/src/main/java/androidx/car/app/model/Tab.java b/car/app/app/src/main/java/androidx/car/app/model/Tab.java
new file mode 100644
index 0000000..ffadbe7
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/Tab.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.annotations.CarProtocol;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+import androidx.car.app.model.constraints.CarIconConstraints;
+import androidx.car.app.model.constraints.CarTextConstraints;
+
+import java.util.Objects;
+
+/**
+ * Represents a tab with a title and an image. {@link Tab} instances are used by TabTemplate to
+ * display tab headers.
+ */
+@CarProtocol
+@ExperimentalCarApi
+@RequiresCarApi(6)
+public final class Tab implements Content {
+ /** Content ID for an empty Tab object. */
+ private static final String EMPTY_TAB_CONTENT_ID = "EMPTY_TAB_CONTENT_ID";
+
+ @Keep
+ private final boolean mIsActive;
+
+ @Keep
+ @Nullable
+ private final CarText mTitle;
+
+ @Keep
+ @Nullable
+ private final CarIcon mIcon;
+
+ @Keep
+ @NonNull
+ private final String mContentId;
+
+ /**
+ * Returns the title of the tab.
+ *
+ * @see Tab.Builder#setTitle(CharSequence)
+ */
+ @NonNull
+ public CarText getTitle() {
+ return requireNonNull(mTitle);
+ }
+
+ /**
+ * Returns the content ID associated with the tab.
+ *
+ * @see Tab.Builder#setContentId(String)
+ */
+ @NonNull
+ @Override
+ public String getContentId() {
+ return requireNonNull(mContentId);
+ }
+
+ /**
+ * Returns the image to display in the tab
+ *
+ * @see Tab.Builder#setIcon(CarIcon)
+ */
+ @NonNull
+ public CarIcon getIcon() {
+ return requireNonNull(mIcon);
+ }
+
+ /**
+ * Indicates if this is the currently active tab.
+ *
+ * @see Tab.Builder#setActive(boolean)
+ */
+ public boolean isActive() {
+ return mIsActive;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ return "[title: "
+ + CarText.toShortString(mTitle)
+ + ", contentId: "
+ + mContentId
+ + ", icon: "
+ + mIcon
+ + ", isActive "
+ + mIsActive
+ + "]";
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ mTitle,
+ mContentId,
+ mIcon,
+ mIsActive);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof Tab)) {
+ return false;
+ }
+ Tab otherTab = (Tab) other;
+
+ return Objects.equals(mTitle, otherTab.mTitle)
+ && Objects.equals(mContentId, otherTab.mContentId)
+ && Objects.equals(mIcon, otherTab.mIcon)
+ && mIsActive == otherTab.isActive();
+ }
+
+ /**
+ * Creates and returns a new {@link Builder} initialized with this {@link Tab}'s data.
+ *
+ */
+ @NonNull
+ public Tab.Builder toBuilder() {
+ return new Tab.Builder(this);
+ }
+
+ Tab(Tab.Builder builder) {
+ mTitle = builder.mTitle;
+ mIcon = builder.mIcon;
+ mIsActive = builder.mIsActive;
+
+ if (builder.mContentId != null) {
+ mContentId = builder.mContentId;
+ } else {
+ mContentId = EMPTY_TAB_CONTENT_ID;
+ }
+ }
+
+ /** Constructs an empty instance, used by serialization code. */
+ private Tab() {
+ mTitle = null;
+ mContentId = EMPTY_TAB_CONTENT_ID;
+ mIcon = null;
+ mIsActive = false;
+ }
+
+ /** A builder of {@link Tab}. */
+ public static final class Builder {
+ boolean mIsActive;
+
+ @Nullable
+ CarText mTitle;
+
+ @Nullable
+ CarIcon mIcon;
+
+ @Nullable
+ String mContentId;
+
+ /**
+ * Sets the title of the tab.
+ *
+ * <p>Only {@link DistanceSpan}s and {@link DurationSpan}s are supported in the input
+ * string.
+ *
+ * @throws NullPointerException if {@code title} is {@code null}
+ * @throws IllegalArgumentException if {@code title} is empty, of if it contains
+ * unsupported spans
+ */
+ @NonNull
+ public Tab.Builder setTitle(@NonNull CharSequence title) {
+ CarText titleText = CarText.create(requireNonNull(title));
+ if (titleText.isEmpty()) {
+ throw new IllegalArgumentException("The title cannot be null or empty");
+ }
+ CarTextConstraints.TEXT_AND_ICON.validateOrThrow(titleText);
+ mTitle = titleText;
+ return this;
+ }
+
+ /**
+ * Sets the content ID of the tab.
+ *
+ */
+ @NonNull
+ public Tab.Builder setContentId(@NonNull String contentId) {
+ if (requireNonNull(contentId).isEmpty()) {
+ throw new IllegalArgumentException("The content ID cannot be null or empty");
+ }
+ mContentId = contentId;
+ return this;
+ }
+
+ /**
+ * Sets the icon to display in the tab.
+ *
+ * <h4>Icon Sizing Guidance</h4>
+ *
+ * To minimize scaling artifacts across a wide range of car screens, apps should provide
+ * icons targeting a 36 x 36 dp bounding box. If the icon exceeds this maximum size in
+ * either one of the dimensions, it will be scaled down to be centered inside the
+ * bounding box while preserving its aspect ratio.
+ *
+ * <p>See {@link CarIcon} for more details related to providing icon and image resources
+ * that work with different car screen pixel densities.
+ *
+ * @throws NullPointerException if {@code icon} is {@code null}
+ */
+ @NonNull
+ public Tab.Builder setIcon(@NonNull CarIcon icon) {
+ CarIconConstraints.DEFAULT.validateOrThrow(requireNonNull(icon));
+ mIcon = icon;
+ return this;
+ }
+
+ /**
+ * Sets the active state of the tab.
+ */
+ @NonNull
+ public Tab.Builder setActive(boolean isActive) {
+ mIsActive = isActive;
+ return this;
+ }
+
+ /**
+ * Constructs the {@link Tab} defined by this builder.
+ *
+ * @throws IllegalStateException if the tab's title, icon or content ID is not set.
+ */
+ @NonNull
+ public Tab build() {
+ if (mTitle == null) {
+ throw new IllegalStateException("A title must be set for the tab");
+ }
+
+ if (mIcon == null) {
+ throw new IllegalStateException("A icon must be set for the tab");
+ }
+
+ if (mContentId == null) {
+ throw new IllegalStateException(
+ "A content ID must be set for the tab");
+ }
+
+ return new Tab(this);
+ }
+
+ /** Returns an empty {@link Tab.Builder} instance. */
+ public Builder() {
+ }
+
+ /** Creates a new {@link Builder}, populated from the input {@link Tab} */
+ Builder(@NonNull Tab tab) {
+ requireNonNull(tab);
+ mIsActive = tab.isActive();
+ mContentId = tab.getContentId();
+ mIcon = tab.getIcon();
+ mTitle = tab.getTitle();
+ }
+ }
+}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/TabCallbackDelegate.java b/car/app/app/src/main/java/androidx/car/app/model/TabCallbackDelegate.java
new file mode 100644
index 0000000..ddb229b
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/TabCallbackDelegate.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import android.annotation.SuppressLint;
+
+import androidx.annotation.NonNull;
+import androidx.car.app.OnDoneCallback;
+import androidx.car.app.annotations.CarProtocol;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+
+/**
+ * A host-side delegate for sending
+ * {@link androidx.car.app.model.TabTemplate.TabCallback} events to the car app.
+ */
+@CarProtocol
+@ExperimentalCarApi
+@RequiresCarApi(6)
+public interface TabCallbackDelegate {
+ /**
+ * Notifies that the user has selected a tab.
+ *
+ * @param tabContentId the content ID of the selected tab
+ * @param callback the {@link OnDoneCallback} to trigger when the client finishes handling
+ * the event
+ */
+ // This mirrors the AIDL class and is not supported to support an executor as an input.
+ @SuppressLint("ExecutorRegistration")
+ void sendTabSelected(@NonNull String tabContentId, @NonNull OnDoneCallback callback);
+}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/TabCallbackDelegateImpl.java b/car/app/app/src/main/java/androidx/car/app/model/TabCallbackDelegateImpl.java
new file mode 100644
index 0000000..fd89c59
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/TabCallbackDelegateImpl.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.SuppressLint;
+import android.os.RemoteException;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.car.app.IOnDoneCallback;
+import androidx.car.app.OnDoneCallback;
+import androidx.car.app.annotations.CarProtocol;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+import androidx.car.app.utils.RemoteUtils;
+
+/**
+ * Implementation class for {@link TabCallbackDelegate}.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+@CarProtocol
+@ExperimentalCarApi
+@RequiresCarApi(6)
+public class TabCallbackDelegateImpl implements TabCallbackDelegate {
+
+ @Keep
+ @Nullable
+ private final ITabCallback mStubCallback;
+ @Override
+ public void sendTabSelected(@NonNull String tabContentId, @NonNull OnDoneCallback callback) {
+ try {
+ requireNonNull(mStubCallback).onTabSelected(tabContentId,
+ RemoteUtils.createOnDoneCallbackStub(callback));
+ } catch (RemoteException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private TabCallbackDelegateImpl(@NonNull TabTemplate.TabCallback callback) {
+ mStubCallback = new TabCallbackStub(callback);
+ }
+
+ /** For serialization. */
+ private TabCallbackDelegateImpl() {
+ mStubCallback = null;
+ }
+
+ @NonNull
+ // This listener relates to UI event and is expected to be triggered on the main thread.
+ @SuppressLint("ExecutorRegistration")
+ static TabCallbackDelegate create(@NonNull TabTemplate.TabCallback callback) {
+ return new TabCallbackDelegateImpl(callback);
+ }
+
+ @Keep // We need to keep these stub for Bundler serialization logic.
+ private static class TabCallbackStub extends ITabCallback.Stub {
+ private final TabTemplate.TabCallback mCallback;
+
+ TabCallbackStub(TabTemplate.TabCallback callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ public void onTabSelected(String tabContentId, IOnDoneCallback callback) {
+ RemoteUtils.dispatchCallFromHost(
+ callback, "onTabSelected", () -> {
+ mCallback.onTabSelected(tabContentId);
+ return null;
+ }
+ );
+ }
+ }
+}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/TabContents.java b/car/app/app/src/main/java/androidx/car/app/model/TabContents.java
new file mode 100644
index 0000000..e22d8fa
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/TabContents.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.SuppressLint;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.annotations.CarProtocol;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+import androidx.car.app.model.constraints.TabContentsConstraints;
+
+import java.util.Objects;
+
+/**
+ * Represents the contents to display for a selected tab in a {@link TabTemplate}.
+ *
+ */
+@CarProtocol
+@ExperimentalCarApi
+@RequiresCarApi(6)
+public class TabContents implements Content {
+ /**
+ * Content ID for TabContents
+ *
+ * <p>This Content ID will be used to refresh the displayed template in the TabContents.
+ */
+ public static final String CONTENT_ID = "TAB_CONTENTS_CONTENT_ID";
+
+ @Keep
+ @Nullable
+ private Template mTemplate;
+
+ /**
+ * Returns the static content ID associated with TabContents.
+ *
+ * @see TabContents#CONTENT_ID
+ */
+
+ @NonNull
+ @Override
+ public String getContentId() {
+ return CONTENT_ID;
+ }
+
+ /** Returns the wrapped {@link Template} to display as the contents. */
+ @NonNull
+ public Template getTemplate() {
+ return requireNonNull(mTemplate);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "[template: " + mTemplate + "]";
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mTemplate);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof TabContents)) {
+ return false;
+ }
+ TabContents otherTabContents = (TabContents) other;
+
+ return Objects.equals(mTemplate, otherTabContents.mTemplate);
+ }
+
+ TabContents(TabContents.Builder builder) {
+ mTemplate = builder.mTemplate;
+ }
+
+ /** Constructs an empty instance, used by serialization code. */
+ private TabContents() {
+ mTemplate = null;
+ }
+
+ /** A builder of {@link TabContents}. */
+ public static final class Builder {
+ @NonNull
+ Template mTemplate;
+
+ /**
+ * Constructs the {@link TabContents} defined by this builder.
+ */
+ @NonNull
+ public TabContents build() {
+ return new TabContents(this);
+ }
+
+ /**
+ * Creates a {@link TabContents.Builder} instance using the given {@link Template} to
+ * display as contents.
+ *
+ * <h4>Requirements</h4>
+ *
+ * There should be no title, Header{@link Action} or {@link ActionStrip} set on the
+ * template.
+ * The host will ignore these.
+ *
+ * @throws NullPointerException if {@code template} is null
+ * @throws IllegalArgumentException if {@code template} does not meet the requirements
+ */
+ @SuppressLint("ExecutorRegistration")
+ public Builder(@NonNull Template template) {
+ TabContentsConstraints.DEFAULT.validateOrThrow(requireNonNull(template));
+ mTemplate = template;
+ }
+ }
+}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/TabTemplate.java b/car/app/app/src/main/java/androidx/car/app/model/TabTemplate.java
new file mode 100644
index 0000000..4a963df
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/TabTemplate.java
@@ -0,0 +1,306 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static androidx.car.app.model.constraints.ActionsConstraints.ACTIONS_CONSTRAINTS_TABS;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.SuppressLint;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.annotations.CarProtocol;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+import androidx.car.app.model.constraints.TabsConstraints;
+import androidx.car.app.utils.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A template representing a list of tabs and contents for the active tab.
+ *
+ * <h4>Template Restrictions</h4>
+ *
+ * In regards to template refreshes, as described in {@link Screen#onGetTemplate()}, this
+ * template is considered a refresh of a previous one if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state (see {@link TabTemplate.Builder#setLoading}}
+ * , or
+ * <li>The {@link Tab}s structure between the templates has not changed and if the new
+ * template has the same active tab then the contents of that tab hasn't changed. This means that
+ * if the previous template has multiple {@link Tab}s, the new template must have the same
+ * number of tabs with the same title and icon.
+ * </ul>
+ */
+@CarProtocol
+@ExperimentalCarApi
+@RequiresCarApi(6)
+public class TabTemplate implements Template {
+
+ /** A listener for tab selection. */
+ public interface TabCallback {
+ /**
+ * Notifies the selected {@code Tab} has changed.
+ *
+ * <p>The host invokes this callback as the user selects a tab.
+ *
+ * @param tabContentId the content ID of the selected tab.
+ */
+ default void onTabSelected(@NonNull String tabContentId) {
+ }
+ }
+
+ @Keep
+ private final boolean mIsLoading;
+
+ @Keep
+ @Nullable
+ private final TabCallbackDelegate mTabCallbackDelegate;
+
+ @Keep
+ @Nullable
+ private final Action mHeaderAction;
+
+ @Keep
+ @Nullable
+ private final TabContents mTabContents;
+
+ @Keep
+ @Nullable
+ private final List<Tab> mTabs;
+
+ /**
+ * Returns the {@link Action} that is set to be displayed in the header of the template, or
+ * {@code null} if not set.
+ *
+ * @see TabTemplate.Builder#setHeaderAction(Action)
+ */
+ @NonNull
+ public Action getHeaderAction() {
+ return requireNonNull(mHeaderAction);
+ }
+
+ /**
+ * Returns whether the template is loading.
+ *
+ * @see TabTemplate.Builder#setLoading(boolean)
+ */
+ public boolean isLoading() {
+ return mIsLoading;
+ }
+
+ /**
+ * Returns the list of {@link Tab}s in the template.
+ */
+ @NonNull
+ public List<Tab> getTabs() {
+ return CollectionUtils.emptyIfNull(mTabs);
+ }
+
+ /**
+ * Returns the {@link TabContents} for the currently active tab.
+ */
+ @NonNull
+ public TabContents getTabContents() {
+ return requireNonNull(mTabContents);
+ }
+
+ /**
+ * Returns the {@link TabCallbackDelegate} for tab related callbacks.
+ */
+ @NonNull
+ public TabCallbackDelegate getTabCallbackDelegate() {
+ return requireNonNull(mTabCallbackDelegate);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "TabTemplate";
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mIsLoading, mHeaderAction, mTabs, mTabContents);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof TabTemplate)) {
+ return false;
+ }
+ TabTemplate otherTemplate = (TabTemplate) other;
+
+ return mIsLoading == otherTemplate.mIsLoading
+ && Objects.equals(mHeaderAction, otherTemplate.mHeaderAction)
+ && Objects.equals(mTabs, otherTemplate.mTabs)
+ && Objects.equals(mTabContents, otherTemplate.mTabContents);
+ }
+
+ TabTemplate(TabTemplate.Builder builder) {
+ mIsLoading = builder.mIsLoading;
+ mHeaderAction = builder.mHeaderAction;
+ mTabs = CollectionUtils.unmodifiableCopy(builder.mTabs);
+ mTabContents = builder.mTabContents;
+ mTabCallbackDelegate = builder.mTabCallbackDelegate;
+ }
+
+ /** Constructs an empty instance, used by serialization code. */
+ private TabTemplate() {
+ mIsLoading = false;
+ mHeaderAction = null;
+ mTabs = Collections.emptyList();
+ mTabContents = null;
+ mTabCallbackDelegate = null;
+ }
+
+ /** A builder of {@link TabTemplate}. */
+ public static final class Builder {
+ @NonNull
+ final TabCallbackDelegate mTabCallbackDelegate;
+
+ boolean mIsLoading;
+
+ @Nullable
+ Action mHeaderAction;
+
+ final List<Tab> mTabs = new ArrayList<>();
+ @Nullable
+ TabContents mTabContents;
+
+ /**
+ * Sets whether the template is in a loading state.
+ *
+ * <p>If set to {@code true}, the UI will display a loading indicator where the content
+ * would be otherwise. The caller is expected to call {@link
+ * androidx.car.app.Screen#invalidate()} and send the new template content
+ * to the host once the data is ready.
+ *
+ * <p>If set to {@code false}, the UI will display the contents of the template.
+ */
+ @NonNull
+ public TabTemplate.Builder setLoading(boolean isLoading) {
+ mIsLoading = isLoading;
+ return this;
+ }
+
+ /**
+ * Sets the {@link Action} that will be displayed in the header of the template, or
+ * {@code null} to not display an action.
+ *
+ * <p>Unless set with this method, the template will not have a header action.
+ *
+ * <h4>Requirements</h4>
+ *
+ * This template only supports {@link Action#APP_ICON} as a header {@link Action}.
+ *
+ * @throws IllegalArgumentException if {@code headerAction} does not meet the template's
+ * requirements
+ * @throws NullPointerException if {@code headerAction} is {@code null}
+ */
+ @NonNull
+ public TabTemplate.Builder setHeaderAction(@NonNull Action headerAction) {
+ ACTIONS_CONSTRAINTS_TABS.validateOrThrow(
+ Collections.singletonList(requireNonNull(headerAction)));
+ mHeaderAction = headerAction;
+ return this;
+ }
+
+ /**
+ * Sets the {@link TabContents} to show in the template.
+ *
+ * @throws NullPointerException if {@code tabContents} is null
+ */
+ @NonNull
+ public TabTemplate.Builder setTabContents(@NonNull TabContents tabContents) {
+ mTabContents = requireNonNull(tabContents);
+ return this;
+ }
+
+ /**
+ * Adds an {@link Tab} to display in the template.
+ *
+ *
+ * @throws NullPointerException if {@code tab} is {@code null}
+ */
+ @NonNull
+ public TabTemplate.Builder addTab(@NonNull Tab tab) {
+ requireNonNull(tab);
+ mTabs.add(tab);
+ return this;
+ }
+
+ /**
+ * Constructs the template defined by this builder.
+ *
+ * <h4>Requirements</h4>
+ *
+ * The number of {@link Tab}s provided in the template should be between 2 and 4, with only
+ * one tab marked as active.
+ *
+ * <p>A header {@link Action} of type TYPE_APP_ICON is required.
+ *
+ * @throws IllegalStateException if the template is in a loading state but there are
+ * tabs added or vice versa
+ * @throws IllegalArgumentException if the added {@link Tab}(s) or header {@link Action}
+ * does not meet the template's requirements
+ */
+ @NonNull
+ public TabTemplate build() {
+ boolean hasTabs = mTabContents != null && !mTabs.isEmpty();
+ if (mIsLoading && hasTabs) {
+ throw new IllegalStateException(
+ "Template is in a loading state but tabs are added");
+ }
+
+ if (!mIsLoading && !hasTabs) {
+ throw new IllegalStateException(
+ "Template is not in a loading state but does not contain tabs or tab "
+ + "contents");
+ }
+
+ if (hasTabs) {
+ TabsConstraints.DEFAULT.validateOrThrow(mTabs);
+ }
+
+ if (!mIsLoading && mHeaderAction == null) {
+ throw new IllegalArgumentException(
+ "Template requires a Header Action of TYPE_APP_ICON when not in Loading "
+ + "state"
+ );
+ }
+
+ return new TabTemplate(this);
+ }
+
+ /** Creates a {@link TabTemplate.Builder} instance using the given {@link TabCallback}. */
+ @SuppressLint("ExecutorRegistration")
+ public Builder(@NonNull TabCallback callback) {
+ mTabCallbackDelegate = TabCallbackDelegateImpl.create(requireNonNull(callback));
+ }
+ }
+}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/constraints/ActionsConstraints.java b/car/app/app/src/main/java/androidx/car/app/model/constraints/ActionsConstraints.java
index 9b854bd..5c32913 100644
--- a/car/app/app/src/main/java/androidx/car/app/model/constraints/ActionsConstraints.java
+++ b/car/app/app/src/main/java/androidx/car/app/model/constraints/ActionsConstraints.java
@@ -25,6 +25,8 @@
import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
import androidx.car.app.model.Action;
import androidx.car.app.model.Action.ActionType;
import androidx.car.app.model.CarText;
@@ -140,6 +142,15 @@
.setOnClickListenerAllowed(true)
.build();
+ /** Constraints for TabTemplate. */
+ @NonNull
+ @ExperimentalCarApi
+ @RequiresCarApi(6)
+ public static final ActionsConstraints ACTIONS_CONSTRAINTS_TABS =
+ new ActionsConstraints.Builder(ACTIONS_CONSTRAINTS_HEADER)
+ .addRequiredActionType(Action.TYPE_APP_ICON)
+ .build();
+
private final int mMaxActions;
private final int mMaxPrimaryActions;
private final int mMaxCustomTitles;
diff --git a/car/app/app/src/main/java/androidx/car/app/model/constraints/TabContentsConstraints.java b/car/app/app/src/main/java/androidx/car/app/model/constraints/TabContentsConstraints.java
new file mode 100644
index 0000000..b1d917901
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/constraints/TabContentsConstraints.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 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 androidx.car.app.model.constraints;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+import androidx.car.app.model.CarText;
+import androidx.car.app.model.GridTemplate;
+import androidx.car.app.model.ListTemplate;
+import androidx.car.app.model.MessageTemplate;
+import androidx.car.app.model.PaneTemplate;
+import androidx.car.app.model.SearchTemplate;
+import androidx.car.app.model.Template;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Encapsulates the constraints to apply when creating {@link TabContents}.
+ *
+ * @hide
+ */
+@ExperimentalCarApi
+@RequiresCarApi(6)
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class TabContentsConstraints {
+
+ /** Allow restricted set of templates as contents for a tab **/
+ @NonNull
+ public static final TabContentsConstraints DEFAULT =
+ new TabContentsConstraints(Arrays.asList(
+ ListTemplate.class,
+ PaneTemplate.class,
+ GridTemplate.class,
+ MessageTemplate.class,
+ SearchTemplate.class
+ ));
+ private HashSet<Class<? extends Template>> mAllowedTemplateTypes;
+
+ /**
+ * Returns {@code true} if the {@link CarText} meets the constraints' requirement.
+ *
+ * @throws IllegalArgumentException if any span types are not allowed
+ */
+ public void validateOrThrow(@NonNull Template template) {
+ if (!mAllowedTemplateTypes.contains(template.getClass())) {
+ throw new IllegalArgumentException(
+ "Type is not allowed in tabs: " + template.getClass().getSimpleName());
+ }
+ }
+
+ private TabContentsConstraints(List<Class<? extends Template>> allowedTypes) {
+ mAllowedTemplateTypes = new HashSet<>(allowedTypes);
+ }
+}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/constraints/TabsConstraints.java b/car/app/app/src/main/java/androidx/car/app/model/constraints/TabsConstraints.java
new file mode 100644
index 0000000..9b089a6
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/model/constraints/TabsConstraints.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 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 androidx.car.app.model.constraints;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.car.app.annotations.ExperimentalCarApi;
+import androidx.car.app.annotations.RequiresCarApi;
+import androidx.car.app.model.Tab;
+
+import java.util.List;
+
+/**
+ * Encapsulates the constraints to apply when creating {@link TabTemplate}.
+ *
+ * @hide
+ */
+@ExperimentalCarApi
+@RequiresCarApi(6)
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class TabsConstraints {
+ private static final int MAXIMUM_ALLOWED_TABS = 4;
+ private static final int MINIMUM_REQUIRED_TABS = 2;
+
+ @NonNull
+ public static final TabsConstraints DEFAULT =
+ new TabsConstraints.Builder()
+ .setMaxTabs(MAXIMUM_ALLOWED_TABS)
+ .setMinTabs(MINIMUM_REQUIRED_TABS)
+ .build();
+
+ private final int mMaxTabs;
+ private final int mMinTabs;
+
+ /**
+ * Validates that the {@link Tab}s satisfies this {@link TabConstraints} instance.
+ *
+ * @throws IllegalArgumentException if the constraints are not met
+ */
+ public void validateOrThrow(@NonNull List<Tab> tabs) {
+ if (tabs.size() < mMinTabs) {
+ throw new IllegalArgumentException(
+ "Number of tabs set do not meet the minimum requirement of " + mMinTabs
+ + " tabs");
+ }
+
+ if (tabs.size() > mMaxTabs) {
+ throw new IllegalArgumentException(
+ "Number of tabs set exceed the maximum allowed size of " + mMaxTabs);
+ }
+
+ int numOfActiveTabs = 0;
+ for (Tab tab : tabs) {
+ if (tab.isActive()) {
+ numOfActiveTabs++;
+ }
+ }
+ if (numOfActiveTabs == 0) {
+ throw new IllegalArgumentException("An active tab is required");
+ }
+ if (numOfActiveTabs > 1) {
+ throw new IllegalArgumentException("Only one active tab is allowed");
+ }
+ }
+
+ TabsConstraints(Builder builder) {
+ mMaxTabs = builder.mMaxTabs;
+ mMinTabs = builder.mMinTabs;
+ }
+
+ /**
+ * A builder of {@link TabsConstraints}.
+ */
+ public static final class Builder {
+ int mMaxTabs = Integer.MAX_VALUE;
+ int mMinTabs = 0;
+
+ /** Sets the maximum number of tabs allowed to be added. */
+ @NonNull
+ public TabsConstraints.Builder setMaxTabs(int maxTabs) {
+ mMaxTabs = maxTabs;
+ return this;
+ }
+
+ /** Sets the minimum number of tabs required to be added. */
+ @NonNull
+ public TabsConstraints.Builder setMinTabs(int minTabs) {
+ mMinTabs = minTabs;
+ return this;
+ }
+
+ /**
+ * Constructs the {@link TabsConstraints} defined by this builder.
+ */
+ @NonNull
+ public TabsConstraints build() {
+ return new TabsConstraints(this);
+ }
+
+ public Builder() {
+ }
+ }
+}
diff --git a/car/app/app/src/test/java/androidx/car/app/model/TabCallbackDelegateTest.java b/car/app/app/src/test/java/androidx/car/app/model/TabCallbackDelegateTest.java
new file mode 100644
index 0000000..bc29cd1
--- /dev/null
+++ b/car/app/app/src/test/java/androidx/car/app/model/TabCallbackDelegateTest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import androidx.car.app.OnDoneCallback;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link TabCallbackDelegateImpl}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class TabCallbackDelegateTest {
+ @Rule
+ public final MockitoRule mockito = MockitoJUnit.rule();
+
+ @Mock
+ TabTemplate.TabCallback mMockTabCallback;
+
+ @Test
+ public void sendTabSelected() {
+ TabCallbackDelegate delegate = TabCallbackDelegateImpl.create(mMockTabCallback);
+
+ OnDoneCallback >
+ delegate.sendTabSelected("MOCK_TAB_ID", onDoneCallback);
+ verify(mMockTabCallback).onTabSelected("MOCK_TAB_ID");
+ verify(onDoneCallback).onSuccess(null);
+ verify(onDoneCallback, never()).onFailure(any());
+ }
+}
diff --git a/car/app/app/src/test/java/androidx/car/app/model/TabContentsTest.java b/car/app/app/src/test/java/androidx/car/app/model/TabContentsTest.java
new file mode 100644
index 0000000..bc16980
--- /dev/null
+++ b/car/app/app/src/test/java/androidx/car/app/model/TabContentsTest.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import androidx.car.app.navigation.model.MapTemplate;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link TabContents}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class TabContentsTest {
+
+ @Test
+ public void createInstance_nullTemplate_Throws() {
+ assertThrows(
+ NullPointerException.class,
+ () -> new TabContents.Builder(null).build());
+ }
+
+ @Test
+ public void createInstance_invalidTemplate_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new TabContents.Builder(new MapTemplate.Builder().build()).build());
+ }
+
+ @Test
+ public void createInstance_listTemplate() {
+ ListTemplate listTemplate = new ListTemplate.Builder()
+ .setSingleList(
+ new ItemList.Builder().addItem(
+ new Row.Builder()
+ .setTitle("Row").addText("text1").build())
+ .build())
+ .build();
+ TabContents tabContents = new TabContents.Builder(listTemplate).build();
+
+ assertEquals(listTemplate, tabContents.getTemplate());
+ }
+
+ @Test
+ public void createInstance_messageTemplate() {
+ MessageTemplate template = new MessageTemplate.Builder("title")
+ .addAction(
+ new Action.Builder()
+ .setTitle("Click")
+ .build())
+ .build();
+ TabContents tabContents = new TabContents.Builder(template).build();
+
+ assertEquals(template, tabContents.getTemplate());
+ }
+
+ @Test
+ public void equals() {
+ MessageTemplate template = new MessageTemplate.Builder("title")
+ .addAction(
+ new Action.Builder()
+ .setTitle("Click")
+ .build())
+ .build();
+ TabContents contents1 = new TabContents.Builder(template).build();
+ TabContents contents2 = new TabContents.Builder(template).build();
+
+ assertEquals(contents1, contents2);
+ }
+
+ @Test
+ public void notEquals_differentTemplate() {
+ MessageTemplate template1 = new MessageTemplate.Builder("title1")
+ .addAction(
+ new Action.Builder()
+ .setTitle("Click1")
+ .build())
+ .build();
+ MessageTemplate template2 = new MessageTemplate.Builder("title2")
+ .addAction(
+ new Action.Builder()
+ .setTitle("Click2")
+ .build())
+ .build();
+ TabContents contents1 = new TabContents.Builder(template1).build();
+ TabContents contents2 = new TabContents.Builder(template2).build();
+
+ assertNotEquals(contents1, contents2);
+ }
+
+ @Test
+ public void notEquals_differentTemplateType() {
+ MessageTemplate template1 = new MessageTemplate.Builder("title")
+ .addAction(
+ new Action.Builder()
+ .setTitle("Click")
+ .build())
+ .build();
+ PaneTemplate template2 = new PaneTemplate.Builder(
+ new Pane.Builder()
+ .addRow(new Row.Builder().setTitle("title").build())
+ .build())
+ .build();
+ TabContents contents1 = new TabContents.Builder(template1).build();
+ TabContents contents2 = new TabContents.Builder(template2).build();
+
+ assertNotEquals(contents1, contents2);
+ }
+}
diff --git a/car/app/app/src/test/java/androidx/car/app/model/TabTemplateTest.java b/car/app/app/src/test/java/androidx/car/app/model/TabTemplateTest.java
new file mode 100644
index 0000000..8d1cbd8
--- /dev/null
+++ b/car/app/app/src/test/java/androidx/car/app/model/TabTemplateTest.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import androidx.car.app.TestUtils;
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link TabTemplate}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class TabTemplateTest {
+ @Rule
+ public final MockitoRule mockito = MockitoJUnit.rule();
+ @Mock
+ TabTemplate.TabCallback mMockTabCallback;
+
+ private static final TabContents TAB_CONTENTS = new TabContents.Builder(
+ new ListTemplate.Builder()
+ .setSingleList(new ItemList.Builder()
+ .addItem(new Row.Builder()
+ .setTitle("Row").addText("text1").build())
+ .build())
+ .build())
+ .build();
+
+ @Test
+ public void createInstance_emptyTemplate_notLoading_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new TabTemplate.Builder(mMockTabCallback).build());
+
+ // Positive case
+ new TabTemplate.Builder(mMockTabCallback).setLoading(true).build();
+ }
+
+ @Test
+ public void createInstance_isLoading_hasTabsAndTabContent_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ new TabTemplate.Builder(mMockTabCallback)
+ .setLoading(true)
+ .addTab(getTab("TAB_1", true))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void createInstance_onlyOneTab_Throws() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void createInstance_moreThanOneActiveTab_Throws() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", true))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void createInstance_moreThanFourTabs_Throws() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .addTab(getTab("TAB_3", false))
+ .addTab(getTab("TAB_4", false))
+ .addTab(getTab("TAB_5", false))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void createInstance_invalidHeaderAction_Throws() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.BACK)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void createInstance_noTabContents_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .build());
+ }
+
+ @Test
+ public void equals() {
+ TabTemplate template1 = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ TabTemplate template2 = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ assertEquals(template1, template2);
+ }
+
+ @Test
+ public void notEquals_differentTabs() {
+ TabTemplate template = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ assertThat(template)
+ .isNotEqualTo(
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_2", true))
+ .addTab(getTab("TAB_3", false))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void notEquals_differentNumberOfTabs() {
+ TabTemplate template = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .addTab(getTab("TAB_3", false))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ assertThat(template)
+ .isNotEqualTo(
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_2", true))
+ .addTab(getTab("TAB_3", false))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void notEquals_differentActiveTab() {
+ TabTemplate template1 = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ TabTemplate template2 = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", false))
+ .addTab(getTab("TAB_2", true))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ assertNotEquals(template1, template2);
+ }
+
+ @Test
+ public void notEquals_differentTabContent() {
+ ItemList itemList = new ItemList.Builder().build();
+
+ ListTemplate listTemplate =
+ new ListTemplate.Builder().setSingleList(itemList).build();
+
+ TabTemplate template = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(new TabContents.Builder(listTemplate).build())
+ .build();
+
+ assertThat(template)
+ .isNotEqualTo(
+ new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(TAB_CONTENTS)
+ .build());
+ }
+
+ @Test
+ public void createInstance_twoTabs_valid() {
+ TabTemplate template = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ assertEquals(template.getTabs().size(), 2);
+ }
+
+ @Test
+ public void createInstance_fourTabs_valid() {
+ TabTemplate template = new TabTemplate.Builder(mMockTabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(getTab("TAB_1", true))
+ .addTab(getTab("TAB_2", false))
+ .addTab(getTab("TAB_3", false))
+ .addTab(getTab("TAB_4", false))
+ .setTabContents(TAB_CONTENTS)
+ .build();
+
+ assertEquals(template.getTabs().size(), 4);
+ }
+
+ private static Tab getTab(String title, boolean isActive) {
+ return new Tab.Builder()
+ .setContentId(title)
+ .setIcon(TestUtils.getTestCarIcon(
+ ApplicationProvider.getApplicationContext(),
+ "ic_test_1"))
+ .setActive(isActive)
+ .setTitle(title)
+ .build();
+ }
+}
diff --git a/car/app/app/src/test/java/androidx/car/app/model/TabTest.java b/car/app/app/src/test/java/androidx/car/app/model/TabTest.java
new file mode 100644
index 0000000..702c572
--- /dev/null
+++ b/car/app/app/src/test/java/androidx/car/app/model/TabTest.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright 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 androidx.car.app.model;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import androidx.car.app.TestUtils;
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link Tab}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class TabTest {
+
+ private static final Tab TEST_TAB = new Tab.Builder()
+ .setTitle("title")
+ .setIcon(TestUtils.getTestCarIcon(ApplicationProvider.getApplicationContext(),
+ "ic_test_1"))
+ .setContentId("id")
+ .build();
+
+ @Test
+ public void createInstance_emptyTab_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new Tab.Builder().build());
+ }
+
+ @Test
+ public void createInstance_missingTitle_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new Tab.Builder()
+ .setIcon(TestUtils.getTestCarIcon(
+ ApplicationProvider.getApplicationContext(),
+ "ic_test_1"))
+ .setContentId("id")
+ .build());
+ }
+
+ @Test
+ public void createInstance_missingIcon_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new Tab.Builder()
+ .setTitle("title")
+ .setContentId("id")
+ .build());
+ }
+
+ @Test
+ public void createInstance_missingContentId_Throws() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new Tab.Builder()
+ .setTitle("title")
+ .setIcon(TestUtils.getTestCarIcon(
+ ApplicationProvider.getApplicationContext(),
+ "ic_test_1"))
+ .build());
+ }
+
+ @Test
+ public void createInstance_valid() {
+ Tab tab = new Tab.Builder()
+ .setTitle("title")
+ .setIcon(TestUtils.getTestCarIcon(
+ ApplicationProvider.getApplicationContext(),
+ "ic_test_1"))
+ .setContentId("id")
+ .build();
+
+ assertFalse(tab.isActive());
+ }
+
+ @Test
+ public void equals() {
+ Tab tab = new Tab.Builder()
+ .setTitle("title")
+ .setIcon(TestUtils.getTestCarIcon(
+ ApplicationProvider.getApplicationContext(),
+ "ic_test_1"))
+ .setContentId("id")
+ .setActive(false)
+ .build();
+
+ assertEquals(tab, TEST_TAB);
+ }
+
+ @Test
+ public void equals_Builder() {
+ Tab tab = TEST_TAB.toBuilder().build();
+
+ assertEquals(tab, TEST_TAB);
+ }
+
+ @Test
+ public void notEquals_differentTitle() {
+ Tab tab = TEST_TAB.toBuilder().setTitle("New Tab").build();
+
+ assertNotEquals(tab, TEST_TAB);
+ }
+
+ @Test
+ public void notEquals_differentIcon() {
+ Tab tab = TEST_TAB.toBuilder()
+ .setIcon(TestUtils.getTestCarIcon(
+ ApplicationProvider.getApplicationContext(),
+ "ic_test_2"))
+ .build();
+
+ assertNotEquals(tab, TEST_TAB);
+ }
+
+ @Test
+ public void notEquals_differentContentId() {
+ Tab tab = TEST_TAB.toBuilder().setContentId("new id").build();
+
+ assertNotEquals(tab, TEST_TAB);
+ }
+
+ @Test
+ public void notEquals_differentActiveState() {
+ Tab tab = TEST_TAB.toBuilder().setActive(true).build();
+
+ assertNotEquals(tab, TEST_TAB);
+ }
+}