[go: nahoru, domu]

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);
+    }
+}