summaryrefslogtreecommitdiff
path: root/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view
diff options
context:
space:
mode:
Diffstat (limited to 'Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view')
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractSurfaceTemplatePresenter.java166
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplatePresenter.java382
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplateView.java466
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/PanZoomManager.java219
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceProvider.java103
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceViewContainer.java146
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverter.java38
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverterRegistry.java68
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenter.java145
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterFactory.java36
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterRegistry.java75
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateTransitionManager.java27
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ActionButtonListParams.java144
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextParams.java237
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextUtils.java484
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DateTimeUtils.java128
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DistanceUtils.java99
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageUtils.java648
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageViewParams.java258
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/widget/map/AbstractMapViewContainer.java48
20 files changed, 3917 insertions, 0 deletions
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractSurfaceTemplatePresenter.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractSurfaceTemplatePresenter.java
new file mode 100644
index 0000000..4fdbe4e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractSurfaceTemplatePresenter.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+
+import android.graphics.Rect;
+import android.view.View;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+
+/**
+ * Abstract base class for {@link TemplatePresenter}s which have a {@link
+ * androidx.car.app.SurfaceContainer}.
+ */
+public abstract class AbstractSurfaceTemplatePresenter extends AbstractTemplatePresenter
+ implements PanZoomManager.Delegate {
+ /** The time threshold between touch events for 30fps updates. */
+ private static final long TOUCH_UPDATE_THRESHOLD_MILLIS = 30;
+
+ /** The amount in pixels to pan with a rotary nudge. */
+ private static final float ROTARY_NUDGE_PAN_PIXELS = 50f;
+
+ private final OnGlobalLayoutListener mGlobalLayoutListener =
+ new OnGlobalLayoutListener() {
+ @SuppressWarnings("nullness") // suppress under initialization warning for this
+ @Override
+ public void onGlobalLayout() {
+ if (mShouldUpdateVisibleArea) {
+ AbstractSurfaceTemplatePresenter.this.updateVisibleArea();
+ mShouldUpdateVisibleArea = false;
+ }
+ }
+ };
+
+ /** Gesture manager that handles pan and zoom gestures in map-based template presenters. */
+ private final PanZoomManager mPanZoomManager;
+
+ /**
+ * A boolean flag that indicates whether the visible area should be updated in the next layout
+ * phase.
+ */
+ private boolean mShouldUpdateVisibleArea;
+
+ /**
+ * Constructs a new instance of a {@link AbstractTemplatePresenter} with the given {@link
+ * Template}.
+ */
+ @SuppressWarnings({"nullness:method.invocation", "nullness:assignment", "nullness:argument"})
+ public AbstractSurfaceTemplatePresenter(
+ TemplateContext templateContext,
+ TemplateWrapper templateWrapper,
+ StatusBarState statusBarState) {
+ super(templateContext, templateWrapper, statusBarState);
+
+ mPanZoomManager =
+ new PanZoomManager(
+ templateContext, this, getTouchUpdateThresholdMillis(), getRotaryNudgePanPixels());
+ }
+
+ @Override
+ public void onPause() {
+ getView().getViewTreeObserver().removeOnGlobalLayoutListener(mGlobalLayoutListener);
+ getView().setOnTouchListener(null);
+ getView().setOnGenericMotionListener(null);
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ getView().getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
+
+ L.d(
+ LogTags.TEMPLATE,
+ "Pan and zoom is %s in %s",
+ isPanAndZoomEnabled() ? "ENABLED" : "DISABLED",
+ getTemplate());
+ if (isPanAndZoomEnabled()) {
+ getView().setOnTouchListener(mPanZoomManager);
+ getView().setOnGenericMotionListener(mPanZoomManager);
+ }
+ }
+
+ @Override
+ public boolean usesSurface() {
+ return true;
+ }
+
+ @Override
+ public boolean isFullScreen() {
+ return false;
+ }
+
+ /** Adjusts the {@code inset} according to the views visible on screen. */
+ public abstract void calculateAdditionalInset(Rect inset);
+
+ @Override
+ public void onPanModeChanged(boolean isInPanMode) {
+ // No-op by default
+ }
+
+ /** Returns whether the pan and zoom feature is enabled. */
+ public boolean isPanAndZoomEnabled() {
+ return false;
+ }
+
+ /** Returns the time threshold in milliseconds for processing touch events. */
+ public long getTouchUpdateThresholdMillis() {
+ return TOUCH_UPDATE_THRESHOLD_MILLIS;
+ }
+
+ /** Returns the amount in pixels to pan with a rotary nudge. */
+ public float getRotaryNudgePanPixels() {
+ return ROTARY_NUDGE_PAN_PIXELS;
+ }
+
+ /** Returns the {@link OnGlobalLayoutListener} instance attached to the view tree. */
+ @VisibleForTesting
+ public OnGlobalLayoutListener getGlobalLayoutListener() {
+ return mGlobalLayoutListener;
+ }
+
+ /** Returns the {@link PanZoomManager} instance associated with this presenter. */
+ protected PanZoomManager getPanZoomManager() {
+ return mPanZoomManager;
+ }
+
+ /** Requests an update to the surface's visible area information. */
+ protected void requestVisibleAreaUpdate() {
+ // Flip the flag so that we will update the visible area in our next layout phase. We cannot
+ // just update the visible area here because the views are not laid out when they are just
+ // inflated, which means that we cannot use the view coordinates to calculate where the views
+ // are not drawn.
+ mShouldUpdateVisibleArea = true;
+ }
+
+ private void updateVisibleArea() {
+ View rootView = getView();
+ Rect safeAreaInset = new Rect();
+ safeAreaInset.left = rootView.getLeft() + rootView.getPaddingLeft();
+ safeAreaInset.top = rootView.getTop() + rootView.getPaddingTop();
+ safeAreaInset.bottom = rootView.getBottom() - rootView.getPaddingBottom();
+ safeAreaInset.right = rootView.getRight() - rootView.getPaddingRight();
+ calculateAdditionalInset(safeAreaInset);
+ getTemplateContext().getSurfaceInfoProvider().setVisibleArea(safeAreaInset);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplatePresenter.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplatePresenter.java
new file mode 100644
index 0000000..679b90e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplatePresenter.java
@@ -0,0 +1,382 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import static android.view.View.VISIBLE;
+import static java.lang.Math.max;
+
+import android.graphics.Insets;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.ViewTreeObserver.OnGlobalFocusChangeListener;
+import android.view.ViewTreeObserver.OnTouchModeChangeListener;
+import android.view.WindowInsets;
+import androidx.annotation.CallSuper;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.Lifecycle.State;
+import androidx.lifecycle.LifecycleRegistry;
+import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.logging.TelemetryEvent;
+import com.android.car.libraries.apphost.logging.TelemetryEvent.UiAction;
+import com.android.car.libraries.apphost.logging.TelemetryHandler;
+import java.util.List;
+
+/**
+ * Abstract base class for {@link TemplatePresenter}s which implements some of the common presenter
+ * functionality.
+ */
+public abstract class AbstractTemplatePresenter implements TemplatePresenter {
+ /**
+ * Test-only override for {@link #hasWindowFocus()}, since robolectric does not set the window
+ * focus properly.
+ */
+ @VisibleForTesting public Boolean mHasWindowFocusOverride;
+
+ private final TemplateContext mTemplateContext;
+ private final LifecycleRegistry mLifecycleRegistry;
+ private final StatusBarState mStatusBarState;
+
+ private TemplateWrapper mTemplateWrapper;
+
+ /** The last focused view before the presenter was refreshed. */
+ @Nullable private View mLastFocusedView;
+
+ /**
+ * Returns a callback called when the presenter view's touch mode changes.
+ *
+ * @see #restoreFocus() for details on how we work around a focus-related GMS core bug
+ */
+ private final OnTouchModeChangeListener mOnTouchModeChangeListener =
+ new OnTouchModeChangeListener() {
+ @SuppressWarnings("nullness") // suppress under initialization warning for this
+ @Override
+ public void onTouchModeChanged(boolean isInTouchMode) {
+ if (!isInTouchMode) {
+ restoreFocus();
+ }
+ }
+ };
+
+ /**
+ * Returns a callback called when the presenter view's focus changes.
+ *
+ * @see #restoreFocus() for details on how we work around a focus-related GMS core bug
+ */
+ private final OnGlobalFocusChangeListener mOnGlobalFocusChangeListener =
+ new OnGlobalFocusChangeListener() {
+ @SuppressWarnings("nullness") // suppress under initialization warning for this
+ @Override
+ public void onGlobalFocusChanged(View oldFocus, View newFocus) {
+ if (newFocus != null) {
+ setLastFocusedView(newFocus);
+ }
+ }
+ };
+
+ /**
+ * Constructs a new instance of a {@link AbstractTemplatePresenter} with the given {@link
+ * Template}.
+ */
+ // Suppress under-initialization checker warning for passing this to the LifecycleRegistry's
+ // ctor.
+ @SuppressWarnings({"nullness:assignment", "nullness:argument"})
+ public AbstractTemplatePresenter(
+ TemplateContext templateContext,
+ TemplateWrapper templateWrapper,
+ StatusBarState statusBarState) {
+ mTemplateContext = templateContext;
+ mTemplateWrapper = TemplateWrapper.copyOf(templateWrapper);
+ mStatusBarState = statusBarState;
+ mLifecycleRegistry = new LifecycleRegistry(this);
+ }
+
+ /** Sets the template this presenter will produce the views for. */
+ @Override
+ public void setTemplate(TemplateWrapper templateWrapper) {
+ mTemplateWrapper = TemplateWrapper.copyOf(templateWrapper);
+
+ onTemplateChanged();
+
+ if (!templateWrapper.isRefresh()) {
+ // Some presenters may get reused even if the template is not a refresh of the previous one.
+ // In those instances, we want the focus to be set to where the default focus should be
+ // instead of last focussed element. Specifically, we want to clear existing focus first,
+ // because if the previous focus was on a row item, and the list is reused and scrolled
+ // to the top, calling setDefaultFocus itself would not reset the focus back to the first
+ // row item.
+ getView().clearFocus();
+ setDefaultFocus();
+ } else {
+ View focusedView = getView().findFocus();
+ if (focusedView != null && focusedView.getVisibility() == VISIBLE) {
+ setLastFocusedView(focusedView);
+ } else {
+ setDefaultFocus();
+ }
+ }
+ }
+
+ /** Returns the template associated with this presenter. */
+ @Override
+ public Template getTemplate() {
+ return mTemplateWrapper.getTemplate();
+ }
+
+ /**
+ * Returns the {@link TemplateWrapper} instance that wraps the template associated with this
+ * presenter.
+ *
+ * @see #getTemplate()
+ */
+ @Override
+ public TemplateWrapper getTemplateWrapper() {
+ return mTemplateWrapper;
+ }
+
+ /** Returns the {@link TemplateContext} instance associated with this presenter. */
+ @Override
+ public TemplateContext getTemplateContext() {
+ return mTemplateContext;
+ }
+
+ @Override
+ @CallSuper
+ public void onCreate() {
+ L.d(LogTags.TEMPLATE, "Presenter onCreate: %s", this);
+ mLifecycleRegistry.setCurrentState(State.CREATED);
+ }
+
+ @Override
+ @CallSuper
+ public void onDestroy() {
+ L.d(LogTags.TEMPLATE, "Presenter onDestroy: %s", this);
+ mLifecycleRegistry.setCurrentState(State.DESTROYED);
+ }
+
+ @Override
+ @CallSuper
+ public void onStart() {
+ L.d(LogTags.TEMPLATE, "Presenter onStart: %s", this);
+ mLifecycleRegistry.setCurrentState(State.STARTED);
+ }
+
+ @Override
+ @CallSuper
+ public void onStop() {
+ L.d(LogTags.TEMPLATE, "Presenter onStop: %s", this);
+ mLifecycleRegistry.setCurrentState(State.CREATED);
+ }
+
+ @Override
+ @CallSuper
+ public void onResume() {
+ L.d(LogTags.TEMPLATE, "Presenter onResume: %s", this);
+ mLifecycleRegistry.setCurrentState(State.RESUMED);
+ mTemplateContext.getStatusBarManager().setStatusBarState(mStatusBarState, getView());
+
+ ViewTreeObserver viewTreeObserver = getView().getViewTreeObserver();
+ viewTreeObserver.addOnTouchModeChangeListener(mOnTouchModeChangeListener);
+ viewTreeObserver.addOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener);
+ }
+
+ @Override
+ @CallSuper
+ public void onPause() {
+ L.d(LogTags.TEMPLATE, "Presenter onPause: %s", this);
+ mLifecycleRegistry.setCurrentState(State.STARTED);
+
+ ViewTreeObserver viewTreeObserver = getView().getViewTreeObserver();
+ viewTreeObserver.removeOnTouchModeChangeListener(mOnTouchModeChangeListener);
+ viewTreeObserver.removeOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener);
+
+ if (mTemplateContext.getColorContrastCheckState().checksContrast()) {
+ sendColorContrastTelemetryEvent(mTemplateContext, getTemplate().getClass().getSimpleName());
+ }
+ }
+
+ @Override
+ public void applyWindowInsets(WindowInsets windowInsets, int minimumTopPadding) {
+ int leftInset;
+ int topInset;
+ int rightInset;
+ int bottomInset;
+ if (VERSION.SDK_INT >= VERSION_CODES.R) {
+ Insets insets =
+ windowInsets.getInsets(WindowInsets.Type.systemBars() | WindowInsets.Type.ime());
+ leftInset = insets.left;
+ topInset = insets.top;
+ rightInset = insets.right;
+ bottomInset = insets.bottom;
+
+ } else {
+ leftInset = windowInsets.getSystemWindowInsetLeft();
+ topInset = windowInsets.getSystemWindowInsetTop();
+ rightInset = windowInsets.getSystemWindowInsetRight();
+ bottomInset = windowInsets.getSystemWindowInsetBottom();
+ }
+
+ View v = getView();
+ v.setPadding(leftInset, max(topInset, minimumTopPadding), rightInset, bottomInset);
+ }
+
+ @Override
+ public boolean setDefaultFocus() {
+ View defaultFocusedView = getDefaultFocusedView();
+ if (defaultFocusedView != null) {
+ defaultFocusedView.requestFocus();
+ setLastFocusedView(defaultFocusedView);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent keyEvent) {
+ return false;
+ }
+
+ @Override
+ public boolean onPreDraw() {
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "["
+ + Integer.toHexString(hashCode())
+ + ": "
+ + mTemplateWrapper.getTemplate().getClass().getSimpleName()
+ + "]";
+ }
+
+ /** Indicates that the template set in the presenter has changed. */
+ public abstract void onTemplateChanged();
+
+ @Override
+ public Lifecycle getLifecycle() {
+ return mLifecycleRegistry;
+ }
+
+ @Override
+ public boolean handlesTemplateChangeAnimation() {
+ return false;
+ }
+
+ @Override
+ public boolean isFullScreen() {
+ return true;
+ }
+
+ @Override
+ public boolean usesSurface() {
+ return false;
+ }
+
+ /**
+ * Restores the presenter's focus to the last focused view.
+ *
+ * <p>Note: A bug in GMS core causes {@link View#isInTouchMode()} to return {@code true} even in
+ * rotary or touchpad mode (b/128031459). When {@link View#layout(int, int, int, int)} is called,
+ * the focus is cleared if {@link View#isInTouchMode()} returns {@code true}. Because the correct
+ * touch mode value is eventually set, we can work around this issue by setting the {@link
+ * #mLastFocusedView} in when the focus changes and restoring the focus when the touch mode is
+ * {@code false} in a {@link OnTouchModeChangeListener}.
+ *
+ * <p>We call {@link #setLastFocusedView(View)} in these places:
+ *
+ * <ul>
+ * <li>In {@link #setDefaultFocus()}: after the presenter is created.
+ * <li>In {@link #setTemplate(TemplateWrapper)}: when the presenter is updated.
+ * <li>In {@link #mOnGlobalFocusChangeListener}: when the user moves the focus in the presenter.
+ * </ul>
+ */
+ @VisibleForTesting
+ public void restoreFocus() {
+ View view = mLastFocusedView;
+ if (view != null) {
+ view.requestFocus();
+ }
+ }
+
+ /**
+ * Moves focus to one of the {@code toViews} if the focus is present in one of the {@code
+ * fromViews}.
+ *
+ * <p>The focus will move to the first view in {@code toViews} that can take focus.
+ *
+ * @return {@code true} if the focus has been moved, otherwise {@code false}
+ */
+ protected static boolean moveFocusIfPresent(List<View> fromViews, List<View> toViews) {
+ for (View fromView : fromViews) {
+ if (fromView.hasFocus()) {
+ for (View toView : toViews) {
+ if (toView.getVisibility() == VISIBLE && toView.requestFocus()) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /** Returns whether the window containing the presenter's view has focus. */
+ protected boolean hasWindowFocus() {
+ if (mHasWindowFocusOverride != null) {
+ return mHasWindowFocusOverride;
+ }
+
+ return getView().hasWindowFocus();
+ }
+
+ /** Returns the view that should get focus by default. */
+ protected View getDefaultFocusedView() {
+ return getView();
+ }
+
+ /**
+ * Sets the presenter's last focused view.
+ *
+ * @see #restoreFocus() for details on how we work around a focus-related GMS core bug
+ */
+ private void setLastFocusedView(View focusedView) {
+ mLastFocusedView = focusedView;
+ }
+
+ private static void sendColorContrastTelemetryEvent(
+ TemplateContext templateContext, String templateClassName) {
+ TelemetryHandler telemetryHandler = templateContext.getTelemetryHandler();
+ telemetryHandler.logCarAppTelemetry(
+ TelemetryEvent.newBuilder(
+ templateContext.getColorContrastCheckState().getCheckPassed()
+ ? UiAction.COLOR_CONTRAST_CHECK_PASSED
+ : UiAction.COLOR_CONTRAST_CHECK_FAILED,
+ templateContext.getCarAppPackageInfo().getComponentName())
+ .setTemplateClassName(templateClassName));
+
+ // Reset color contrast check state
+ templateContext.getColorContrastCheckState().setCheckPassed(true);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplateView.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplateView.java
new file mode 100644
index 0000000..53aa804
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplateView.java
@@ -0,0 +1,466 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import static com.android.car.libraries.apphost.common.EventManager.EventType.TEMPLATE_TOUCHED_OR_FOCUSED;
+import static com.android.car.libraries.apphost.common.EventManager.EventType.WINDOW_FOCUS_CHANGED;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.ViewTreeObserver.OnPreDrawListener;
+import android.view.ViewTreeObserver.OnWindowFocusChangeListener;
+import android.view.WindowInsets;
+import android.widget.FrameLayout;
+import androidx.annotation.MainThread;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.Lifecycle.State;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.common.ThreadUtils;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.google.common.base.Preconditions;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A view that displays {@link Template}s.
+ *
+ * <p>The current template can be set with {@link #setTemplate} method.
+ */
+public abstract class AbstractTemplateView extends FrameLayout {
+ /**
+ * The {@link TemplatePresenter} for the template currently set in the view or {@code null} if
+ * none is set.
+ */
+ @Nullable private TemplatePresenter mCurrentPresenter;
+
+ /** The {@link Lifecycle} object of the parent of this view (e.g. the car activity hosting it). */
+ @MonotonicNonNull private Lifecycle mParentLifecycle;
+
+ /**
+ * An observer for the {@link #mParentLifecycle}, which is registered and unregistered when the
+ * view is attached and detached.
+ */
+ @Nullable private LifecycleObserver mLifecycleObserver;
+
+ /**
+ * Context for various {@link TemplatePresenter}s to retrieve important bits of information for
+ * presenting content.
+ */
+ @MonotonicNonNull private TemplateContext mTemplateContext;
+
+ /** {@link WindowInsets} to apply to templates. */
+ @MonotonicNonNull private WindowInsets mWindowInsets;
+
+ /**
+ * The window focus value in the last callback from the {@link OnWindowFocusChangeListener}.
+ *
+ * <p>We need to store this value because the listener is called even if the window focus state
+ * does not change, when the view focus moves.
+ */
+ private boolean mLastWindowFocusState;
+
+ /** A callback called when the template view's window focus changes. */
+ private final OnWindowFocusChangeListener mOnWindowFocusChangeListener =
+ new OnWindowFocusChangeListener() {
+ @SuppressWarnings("nullness") // suppress under initialization warning for this
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ if (hasFocus != mLastWindowFocusState) {
+ // Dispatch the window focus event only when the window focus state changes.
+ dispatchWindowFocusEvent();
+ mLastWindowFocusState = hasFocus;
+ }
+ }
+ };
+
+ private final OnPreDrawListener mOnPreDrawListener =
+ new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ TemplatePresenter presenter = mCurrentPresenter;
+ if (presenter != null) {
+ return presenter.onPreDraw();
+ }
+ return true;
+ }
+ };
+
+ protected AbstractTemplateView(Context context) {
+ this(context, null);
+ }
+
+ protected AbstractTemplateView(Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ protected AbstractTemplateView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ /**
+ * Returns the {@link SurfaceViewContainer} which holds the surface that 3p apps can use to render
+ * custom content.
+ */
+ protected abstract SurfaceViewContainer getSurfaceViewContainer();
+
+ /** Returns the {@link FrameLayout} container which holds the currently set template. */
+ protected abstract ViewGroup getTemplateContainer();
+
+ /**
+ * Returns the minimum top padding to use when laying out the UI.
+ *
+ * <p>This is used to ensure there is some spacing from top of the screen to the UI when there is
+ * no status bar (i.e. widescreen).
+ */
+ protected abstract int getMinimumTopPadding();
+
+ /**
+ * Returns a {@link TemplateTransitionManager} responsible for handling transitions between
+ * presenters
+ */
+ protected abstract TemplateTransitionManager getTransitionManager();
+
+ /** Returns the current {@link TemplateContext} or {@code null} if one has not been set. */
+ @Nullable
+ protected TemplateContext getTemplateContext() {
+ return mTemplateContext;
+ }
+
+ /**
+ * Returns a {@link SurfaceProvider} which can be used to retrieve the {@link
+ * android.view.Surface} that 3p apps can use to draw custom content.
+ */
+ public SurfaceProvider getSurfaceProvider() {
+ return getSurfaceViewContainer();
+ }
+
+ /**
+ * Sets the parent {@link Lifecycle} for this view.
+ *
+ * <p>This is normally the activity or fragment the view is attached to.
+ */
+ public void setParentLifecycle(Lifecycle parentLifecycle) {
+ mParentLifecycle = parentLifecycle;
+ }
+
+ /** Returns the parent {@link Lifecycle}. */
+ protected @Nullable Lifecycle getParentLifecycle() {
+ return mParentLifecycle;
+ }
+
+ /** Sets the {@link TemplateContext} for this view. */
+ public void setTemplateContext(TemplateContext templateContext) {
+ mTemplateContext = TemplateContext.from(templateContext, getContext());
+ }
+
+ /** Stores the window insets to apply to templates. */
+ public void setWindowInsets(WindowInsets windowInsets) {
+ mWindowInsets = windowInsets;
+ if (mCurrentPresenter != null) {
+ mCurrentPresenter.applyWindowInsets(windowInsets, getMinimumTopPadding());
+ }
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent keyEvent) {
+ dispatchTouchFocusEvent();
+ if (mCurrentPresenter != null && mCurrentPresenter.onKeyUp(keyCode, keyEvent)) {
+ return true;
+ }
+ return super.onKeyUp(keyCode, keyEvent);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent ev) {
+ dispatchTouchFocusEvent();
+ return super.onInterceptTouchEvent(ev);
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(MotionEvent motionEvent) {
+ dispatchTouchFocusEvent();
+ return super.onGenericMotionEvent(motionEvent);
+ }
+
+ @Override
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ if (mParentLifecycle != null) {
+ initLifecycleObserver(mParentLifecycle);
+ }
+
+ ViewTreeObserver viewTreeObserver = getViewTreeObserver();
+ viewTreeObserver.addOnWindowFocusChangeListener(mOnWindowFocusChangeListener);
+ viewTreeObserver.addOnPreDrawListener(mOnPreDrawListener);
+ }
+
+ /** Returns the presenter currently attached to this view. */
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ @Nullable
+ public TemplatePresenter getCurrentPresenter() {
+ return mCurrentPresenter;
+ }
+
+ /** Sets the {@link Template} to display in the view, or {@code null} to display nothing. */
+ @MainThread
+ public void setTemplate(TemplateWrapper templateWrapper) {
+ ThreadUtils.ensureMainThread();
+
+ // First convert the template to another template type if needed.
+ templateWrapper =
+ TemplateConverterRegistry.get().maybeConvertTemplate(getContext(), templateWrapper);
+
+ TemplatePresenter previousPresenter = mCurrentPresenter;
+ if (mCurrentPresenter != null) {
+ TemplatePresenter presenter = mCurrentPresenter;
+
+ Template template = templateWrapper.getTemplate();
+
+ // Allow the existing presenter to update the views if:
+ // 1) Both the previous and the new template are of the same class.
+ // 2) The new template is a refresh OR the presenter handles the template change
+ // animation.
+ boolean updatePresenter = presenter.getTemplate().getClass().equals(template.getClass());
+ updatePresenter &= templateWrapper.isRefresh() || presenter.handlesTemplateChangeAnimation();
+ if (updatePresenter) {
+ updatePresenter(presenter, templateWrapper);
+ return;
+ }
+
+ // The current presenter is not of the same type as the given template, so remove it. We
+ // will create a new presenter below and re-add it if needed.
+ // TODO(b/151953922): Test the ordering of pause, remove view, destroy.
+ pausePresenter(presenter);
+ stopPresenter(presenter);
+ destroyPresenter(presenter);
+ mCurrentPresenter = null;
+ }
+
+ TemplatePresenter presenter = createPresenter(templateWrapper);
+ mCurrentPresenter = presenter;
+ transition(presenter, previousPresenter);
+
+ if (presenter != null) {
+ presenter.setDefaultFocus();
+ }
+ }
+
+ private void transition(@Nullable TemplatePresenter to, @Nullable TemplatePresenter from) {
+ if (to != null) {
+ getTransitionManager()
+ .transition(getTemplateContainer(), getSurfaceViewContainer(), to, from);
+ } else {
+ getSurfaceViewContainer().setVisibility(GONE);
+ View previousView = from == null ? null : from.getView();
+ if (previousView != null) {
+ getTemplateContainer().removeView(previousView);
+ }
+ }
+ }
+
+ /**
+ * Returns the {@link WindowInsets} to apply to the templates presented by the view or {@code
+ * null} if not set.
+ *
+ * @see #setWindowInsets(WindowInsets)
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ @Nullable
+ public WindowInsets getWindowInsets() {
+ return mWindowInsets;
+ }
+
+ @Override
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ public void onDetachedFromWindow() {
+ ViewTreeObserver viewTreeObserver = getViewTreeObserver();
+ viewTreeObserver.removeOnWindowFocusChangeListener(mOnWindowFocusChangeListener);
+ viewTreeObserver.removeOnPreDrawListener(mOnPreDrawListener);
+
+ // Stop the presenter, since its view is no longer visible.
+ TemplatePresenter presenter = mCurrentPresenter;
+ if (presenter != null) {
+ stopPresenter(presenter);
+ }
+
+ if (mLifecycleObserver != null && mParentLifecycle != null) {
+ mParentLifecycle.removeObserver(mLifecycleObserver);
+ mLifecycleObserver = null;
+ }
+
+ super.onDetachedFromWindow();
+ }
+
+ /**
+ * Let any listeners know that an(y) UI element within the template view has been interacted on,
+ * either via touch or focus events.
+ */
+ private void dispatchTouchFocusEvent() {
+ TemplateContext context = mTemplateContext;
+ if (context != null) {
+ context.getEventManager().dispatchEvent(TEMPLATE_TOUCHED_OR_FOCUSED);
+ }
+ }
+
+ /**
+ * Let any listeners know that the window that contains the template view has changed its focus
+ * state.
+ */
+ private void dispatchWindowFocusEvent() {
+ TemplateContext context = mTemplateContext;
+ if (context != null) {
+ context.getEventManager().dispatchEvent(WINDOW_FOCUS_CHANGED);
+ }
+ }
+
+ /** Updates the given presenter with the data from the given template. */
+ private static void updatePresenter(
+ TemplatePresenter presenter, TemplateWrapper templateWrapper) {
+ Preconditions.checkState(
+ presenter.getTemplate().getClass().equals(templateWrapper.getTemplate().getClass()));
+
+ L.d(LogTags.TEMPLATE, "Updating presenter: %s", presenter);
+ presenter.setTemplate(templateWrapper);
+ }
+
+ /** Pauses the given presenter. */
+ private static void pausePresenter(TemplatePresenter presenter) {
+ L.d(LogTags.TEMPLATE, "Pausing presenter: %s", presenter);
+
+ State currentState = presenter.getLifecycle().getCurrentState();
+ if (currentState.isAtLeast(State.RESUMED)) {
+ presenter.onPause();
+ }
+ }
+
+ /** Stops the given presenter. */
+ private static void stopPresenter(TemplatePresenter presenter) {
+ L.d(LogTags.TEMPLATE, "Stopping presenter: %s", presenter);
+
+ State currentState = presenter.getLifecycle().getCurrentState();
+ if (currentState.isAtLeast(State.STARTED)) {
+ presenter.onStop();
+ }
+ }
+
+ /** Destroys the given presenter. */
+ private static void destroyPresenter(TemplatePresenter presenter) {
+ L.d(LogTags.TEMPLATE, "Destroying presenter: %s", presenter);
+
+ presenter.onDestroy();
+ }
+
+ /**
+ * Creates and starts a new presenter for the given template or {@code null} if a presenter could
+ * not be found for it.
+ */
+ @Nullable
+ private TemplatePresenter createPresenter(TemplateWrapper templateWrapper) {
+ if (mTemplateContext == null) {
+ throw new IllegalStateException(
+ "templateContext is null when attempting to create a presenter");
+ }
+
+ TemplatePresenter presenter =
+ TemplatePresenterRegistry.get().createPresenter(mTemplateContext, templateWrapper);
+ if (presenter == null) {
+ L.w(
+ LogTags.TEMPLATE,
+ "No presenter available for template type: %s",
+ templateWrapper.getTemplate().getClass().getSimpleName());
+ return null;
+ }
+
+ L.d(LogTags.TEMPLATE, "Creating new presenter: %s", presenter);
+ presenter.onCreate();
+
+ if (mParentLifecycle != null) {
+ // Only start and resume it if our parent parent lifecycle is in those states. If not,
+ // we will
+ // switch to the state when/if the parent lifecycle reaches it later on.
+ State parentState = mParentLifecycle.getCurrentState();
+ if (parentState.isAtLeast(State.STARTED)) {
+ presenter.onStart();
+ }
+ if (parentState.isAtLeast(State.RESUMED)) {
+ presenter.onResume();
+ }
+ }
+
+ if (mWindowInsets != null) {
+ presenter.applyWindowInsets(mWindowInsets, getMinimumTopPadding());
+ }
+ return presenter;
+ }
+
+ /**
+ * Instantiates a parent lifecycle observer that forwards the relevant events to the current
+ * presenter.
+ */
+ private void initLifecycleObserver(Lifecycle parentLifecycle) {
+ mLifecycleObserver =
+ new DefaultLifecycleObserver() {
+ @Override
+ public void onStart(LifecycleOwner lifecycleOwner) {
+ if (mCurrentPresenter != null) {
+ mCurrentPresenter.onStart();
+ }
+ }
+
+ @Override
+ public void onStop(LifecycleOwner lifecycleOwner) {
+ if (mCurrentPresenter != null) {
+ mCurrentPresenter.onStop();
+ }
+ }
+
+ @Override
+ public void onResume(LifecycleOwner lifecycleOwner) {
+ if (mCurrentPresenter != null) {
+ mCurrentPresenter.onResume();
+ }
+ }
+
+ @Override
+ public void onPause(LifecycleOwner lifecycleOwner) {
+ if (mCurrentPresenter != null) {
+ mCurrentPresenter.onPause();
+ }
+ }
+
+ @Override
+ public void onDestroy(LifecycleOwner lifecycleOwner) {
+ if (mCurrentPresenter != null) {
+ mCurrentPresenter.onDestroy();
+ }
+ }
+ };
+ parentLifecycle.addObserver(mLifecycleObserver);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/PanZoomManager.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/PanZoomManager.java
new file mode 100644
index 0000000..ab61aa9
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/PanZoomManager.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import static com.android.car.libraries.apphost.template.view.model.ActionStripWrapper.INVALID_FOCUSED_ACTION_INDEX;
+
+import android.annotation.SuppressLint;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnGenericMotionListener;
+import android.view.View.OnTouchListener;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.Action;
+import androidx.car.app.model.ActionStrip;
+import com.android.car.libraries.apphost.common.MapGestureManager;
+import com.android.car.libraries.apphost.common.SurfaceCallbackHandler;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.TelemetryEvent;
+import com.android.car.libraries.apphost.logging.TelemetryEvent.UiAction;
+import com.android.car.libraries.apphost.template.view.model.ActionStripWrapper;
+import com.android.car.libraries.apphost.template.view.model.ActionWrapper;
+import java.util.ArrayList;
+import java.util.List;
+
+/** A class that manages responses to the user's pan and zoom actions. */
+public class PanZoomManager implements OnGenericMotionListener, OnTouchListener {
+ /** A delegate class that responds to {@link PanZoomManager}'s actions and queries. */
+ public interface Delegate {
+ /** Called when the pan mode state changes. */
+ void onPanModeChanged(boolean isInPanMode);
+ }
+
+ private final TemplateContext mTemplateContext;
+
+ /** A delegate that responds to {@link PanZoomManager}'s actions and queries. */
+ private final Delegate mDelegate;
+
+ /** Gesture manager that handles gestures in map-based template presenters. */
+ private final MapGestureManager mMapGestureManager;
+
+ /** The amount in pixels to pan with a rotary nudge. */
+ private final float mRotaryNudgePanPixels;
+
+ /**
+ * Indicates the car app is in the pan mode.
+ *
+ * <p>In the pan mode, the pan UI and the map action strip are displayed, and other components
+ * such as the routing card and action strip are hidden.
+ */
+ private boolean mIsInPanMode;
+
+ /** Indicates whether the pan manager is enabled or not. */
+ private boolean mIsEnabled;
+
+ /** Construct a new instance of {@link PanZoomManager}. */
+ public PanZoomManager(
+ TemplateContext templateContext,
+ Delegate delegate,
+ long touchUpdateThresholdMillis,
+ float rotaryNudgePanPixels) {
+ mTemplateContext = templateContext;
+ mDelegate = delegate;
+ mMapGestureManager = new MapGestureManager(templateContext, touchUpdateThresholdMillis);
+ mRotaryNudgePanPixels = rotaryNudgePanPixels;
+ }
+
+ @Override
+ public boolean onGenericMotion(View v, MotionEvent event) {
+ // If we are not in the pan mode or the pan manager is disabled, do not intercept the motion
+ // events. Also, do not intercept rotary controller scrolls.
+ if (!mIsInPanMode || !mIsEnabled || event.getAction() == MotionEvent.ACTION_SCROLL) {
+ return false;
+ }
+
+ handleGesture(event);
+ return true;
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ // Handle gestures only when the pan manager is enabled.
+ if (!mIsEnabled) {
+ return false;
+ }
+
+ handleGesture(event);
+ return true;
+ }
+
+ /** Handles the gesture from the given motion event. */
+ public void handleGesture(MotionEvent event) {
+ mMapGestureManager.handleGesture(event);
+ }
+
+ /**
+ * Handles the rotary inputs by translating them to pan events, if appropriate.
+ *
+ * @return {@code true} if the input was handled, {@code false} otherwise.
+ */
+ public boolean handlePanEventsIfNeeded(int keyCode) {
+ // When in the pan mode, use the rotary nudges for map panning.
+ if (mIsInPanMode) {
+ SurfaceCallbackHandler handler = mTemplateContext.getSurfaceCallbackHandler();
+ if (!handler.canStartNewGesture()) {
+ return false;
+ }
+
+ float distanceX = 0f;
+ float distanceY = 0f;
+ if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
+ distanceX = -mRotaryNudgePanPixels;
+ } else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
+ distanceX = mRotaryNudgePanPixels;
+ } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
+ distanceY = -mRotaryNudgePanPixels;
+ } else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
+ distanceY = mRotaryNudgePanPixels;
+ }
+
+ if (distanceX != 0 || distanceY != 0) {
+ // each rotary nudge is treated as a single gesture.
+ handler.onScroll(distanceX, distanceY);
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(TelemetryEvent.newBuilder(UiAction.ROTARY_PAN));
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Enables or disables the pan manager.
+ *
+ * <p>If the pan mode was active when the pan manager is disabled, it will become inactive.
+ */
+ public void setEnabled(boolean isEnabled) {
+ mIsEnabled = isEnabled;
+
+ if (mIsInPanMode && !isEnabled) {
+ // If the user is in the pan mode but the feature is disabled, exit pan mode.
+ setPanMode(false);
+ }
+ }
+
+ /**
+ * Returns the map {@link ActionStripWrapper} from the given {@link ActionStrip}.
+ *
+ * <p>This method contains the special handling logic for {@link Action#PAN} buttons.
+ */
+ public ActionStripWrapper getMapActionStripWrapper(
+ TemplateContext templateContext, ActionStrip actionStrip) {
+ List<ActionWrapper> mapActions = new ArrayList<>();
+ int focusedActionIndex = INVALID_FOCUSED_ACTION_INDEX;
+
+ int actionIndex = 0;
+ for (Action action : actionStrip.getActions()) {
+ ActionWrapper.Builder builder = new ActionWrapper.Builder(action);
+ if (action.getType() == Action.TYPE_PAN) {
+ if (templateContext.getInputConfig().hasTouch()) {
+ // Hide the pan button in touch screens.
+ continue;
+ } else {
+ // React to the pan button in the rotary and touchpad mode.
+ builder.setOnClickListener(() -> setPanMode(!mIsInPanMode));
+
+ // Keep the focus on the pan button if the user uses a touchpad and is in the pan mode,
+ // because the user cannot move the focus with the touchpad in the pan mode.
+ if (mIsInPanMode && templateContext.getInputConfig().hasTouchpadForUiNavigation()) {
+ focusedActionIndex = actionIndex;
+ }
+ }
+ }
+ mapActions.add(builder.build());
+ actionIndex++;
+ }
+
+ return new ActionStripWrapper.Builder(mapActions)
+ .setFocusedActionIndex(focusedActionIndex)
+ .build();
+ }
+
+ /** Returns whether the pan mode is active or not. */
+ public boolean isInPanMode() {
+ return mIsInPanMode;
+ }
+
+ /**
+ * Sets the pan mode.
+ *
+ * <p>When the pan mode changes, the delegate will be notified of the change.
+ */
+ @VisibleForTesting
+ void setPanMode(boolean isInPanMode) {
+ boolean panModeChanged = mIsInPanMode != isInPanMode;
+ mIsInPanMode = isInPanMode;
+
+ if (panModeChanged) {
+ mDelegate.onPanModeChanged(isInPanMode);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceProvider.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceProvider.java
new file mode 100644
index 0000000..d2b75f4
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceProvider.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import android.view.Surface;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** A provider for {@link Surface}s that can be used by 3p apps to render custom content. */
+public interface SurfaceProvider {
+ /** Listener interface for the {@link SurfaceProvider}. */
+ interface SurfaceProviderListener {
+ /**
+ * Notifies the listener that the surface was created.
+ *
+ * <p>Clients should use this callback to prepare for drawing.
+ */
+ void onSurfaceCreated();
+
+ /**
+ * Notifies the listener that the surface had some structural changes (format or size).
+ *
+ * <p>This is called at least once after {@link #onSurfaceCreated()}. Clients must update the
+ * imagery on the surface.
+ */
+ void onSurfaceChanged();
+
+ /**
+ * Notifies the listener that the surface is being destroyed.
+ *
+ * <p>After returning from this call clients should not try to access the surface anymore. The
+ * {@link SurfaceProvider} is still valid after this call and may be followed by a {@link
+ * #onSurfaceCreated()}.
+ */
+ void onSurfaceDestroyed();
+
+ /** Notifies the listener about a surface scroll touch event. */
+ void onSurfaceScroll(float distanceX, float distanceY);
+
+ /** Notifies the listener about a surface fling touch event. */
+ void onSurfaceFling(float velocityX, float velocityY);
+
+ /** Notifies the listener about a surface scale touch event. */
+ void onSurfaceScale(float focusX, float focusY, float scaleFactor);
+ }
+
+ /**
+ * Sets the listener which is called when {@link Surface} changes such as on creation, destruction
+ * or due to structural changes.
+ */
+ void setListener(@Nullable SurfaceProviderListener listener);
+
+ /** Returns the {@link Surface} that this provider manages. */
+ @Nullable Surface getSurface();
+
+ /** Returns the width of the surface,m in pixels. */
+ int getWidth();
+
+ /** Returns the height of the surface, in pixels. */
+ int getHeight();
+
+ /** The screen density expressed as dots-per-inch. */
+ int getDpi();
+
+ SurfaceProvider EMPTY =
+ new SurfaceProvider() {
+ @Override
+ public void setListener(@Nullable SurfaceProviderListener listener) {}
+
+ @Override
+ @Nullable
+ public Surface getSurface() {
+ return null;
+ }
+
+ @Override
+ public int getWidth() {
+ return 0;
+ }
+
+ @Override
+ public int getHeight() {
+ return 0;
+ }
+
+ @Override
+ public int getDpi() {
+ return 0;
+ }
+ };
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceViewContainer.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceViewContainer.java
new file mode 100644
index 0000000..53d6fa0
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceViewContainer.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import androidx.annotation.VisibleForTesting;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Container of the {@link SurfaceView} which 3p apps can use to render custom content. For example,
+ * navigation apps can use it to draw a map.
+ */
+public class SurfaceViewContainer extends SurfaceView implements SurfaceProvider {
+ /** A listener for changes to {@link SurfaceView}. */
+ @Nullable private SurfaceProviderListener mListener;
+
+ /** Indicates whether the surface is ready for use. */
+ private boolean mIsSurfaceReady;
+
+ private final SurfaceHolder.Callback mSurfaceHolderCallback =
+ new SurfaceHolder.Callback() {
+ @SuppressWarnings("nullness") // suppress under initialization warning for this
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ mIsSurfaceReady = true;
+ notifySurfaceCreated();
+ }
+
+ @SuppressWarnings("nullness") // suppress under initialization warning for this
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ notifySurfaceChanged();
+ }
+
+ @SuppressWarnings("nullness") // suppress under initialization warning for this
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ mIsSurfaceReady = false;
+ notifySurfaceDestroyed();
+ }
+ };
+
+ /** Returns an instance of {@link SurfaceViewContainer}. */
+ public SurfaceViewContainer(Context context) {
+ this(context, null);
+ }
+
+ /**
+ * Returns an instance of {@link SurfaceViewContainer} with the given attribute set.
+ *
+ * @see android.view.View#View(Context, AttributeSet)
+ */
+ public SurfaceViewContainer(Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ /**
+ * Returns an instance of {@link SurfaceViewContainer} with the given attribute set and default
+ * style attribute.
+ *
+ * @see android.view.View#View(Context, AttributeSet, int)
+ */
+ public SurfaceViewContainer(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ @Nullable
+ public Surface getSurface() {
+ if (!mIsSurfaceReady) {
+ L.v(LogTags.TEMPLATE, "Surface is not ready for use");
+ return null;
+ }
+
+ return getHolder().getSurface();
+ }
+
+ @Override
+ public int getDpi() {
+ if (!mIsSurfaceReady) {
+ return 0;
+ }
+
+ return getResources().getDisplayMetrics().densityDpi;
+ }
+
+ @Override
+ public void setListener(@Nullable SurfaceProviderListener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ getHolder().addCallback(mSurfaceHolderCallback);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ getHolder().removeCallback(mSurfaceHolderCallback);
+ }
+
+ /** Returns whether the surface is ready to be used. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ public boolean isSurfaceReady() {
+ return mIsSurfaceReady;
+ }
+
+ private void notifySurfaceCreated() {
+ if (mListener != null) {
+ mListener.onSurfaceCreated();
+ }
+ }
+
+ private void notifySurfaceChanged() {
+ if (mListener != null) {
+ mListener.onSurfaceChanged();
+ }
+ }
+
+ private void notifySurfaceDestroyed() {
+ if (mListener != null) {
+ mListener.onSurfaceDestroyed();
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverter.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverter.java
new file mode 100644
index 0000000..01f843c
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverter.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import android.content.Context;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import java.util.Collection;
+
+/**
+ * Represents a type that can convert a {@link Template} instance into another type of template.
+ *
+ * <p>{@link TemplateConverter}s can be taken advantage to do N:1 conversions between template
+ * types. This allows converting different templates that are similar but for which we want
+ * different types in the client API to a common template that can be used internally be a single
+ * presenter, thus avoiding duplicating the presenter code.
+ */
+public interface TemplateConverter {
+
+ /** Changes the template instance in the template wrapper, if a mapping is necessary. */
+ TemplateWrapper maybeConvertTemplate(Context context, TemplateWrapper templateWrapper);
+
+ /** Returns the list of template types this converter supports. */
+ Collection<Class<? extends Template>> getSupportedTemplates();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverterRegistry.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverterRegistry.java
new file mode 100644
index 0000000..ba976cc
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverterRegistry.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import android.content.Context;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A registry of {@link TemplateConverter} instances.
+ *
+ * <p>It is implemented as a {@link TemplateConverter} that wraps N other {@link
+ * TemplateConverter}s.
+ */
+public class TemplateConverterRegistry implements TemplateConverter {
+ private static final TemplateConverterRegistry INSTANCE = new TemplateConverterRegistry();
+
+ private final Map<Class<? extends Template>, TemplateConverter> mRegistry = new HashMap<>();
+ private final Set<Class<? extends Template>> mSupportedTemplates = new HashSet<>();
+
+ /** Returns a singleton instance of the {@link TemplateConverterRegistry}. */
+ public static TemplateConverterRegistry get() {
+ return INSTANCE;
+ }
+
+ @Override
+ public TemplateWrapper maybeConvertTemplate(Context context, TemplateWrapper templateWrapper) {
+ TemplateConverter converter = mRegistry.get(templateWrapper.getTemplate().getClass());
+ if (converter != null) {
+ return converter.maybeConvertTemplate(context, templateWrapper);
+ }
+ return templateWrapper;
+ }
+
+ @Override
+ public Collection<Class<? extends Template>> getSupportedTemplates() {
+ return Collections.unmodifiableCollection(mSupportedTemplates);
+ }
+
+ /** Registers the given {@link TemplateConverter}. */
+ public void register(TemplateConverter converter) {
+ for (Class<? extends Template> clazz : converter.getSupportedTemplates()) {
+ mRegistry.put(clazz, converter);
+ mSupportedTemplates.add(clazz);
+ }
+ }
+
+ private TemplateConverterRegistry() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenter.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenter.java
new file mode 100644
index 0000000..fa6dcd1
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenter.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.WindowInsets;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import androidx.lifecycle.LifecycleOwner;
+import com.android.car.libraries.apphost.common.TemplateContext;
+
+/**
+ * A presenter is responsible for connecting a {@link Template} model with an Android {@link View}.
+ *
+ * <p>In <a href="https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel">MVVM</a>
+ * terms, the {@link Template} is both the view as well as the data model, the {@link View} owned by
+ * is the view, and {@link TemplatePresenter} is the controller which connects them together.
+ *
+ * <p>A presenter has a lifecycle, and extends {@link LifecycleOwner} to allow registering observers
+ * to it.
+ *
+ * <p>Note the presenter's started and stopped states are dependent upon the parent lifecycle
+ * owner's (e.g. the activity or fragment the template view is attached to). This means for example
+ * that if the owner is not started at the time it is created, the presenter won't be started
+ * either. Conversely, if the parent is stopped and then started, the presenter will also change
+ * states accordingly.
+ */
+public interface TemplatePresenter extends LifecycleOwner {
+
+ /**
+ * Sets the {@link Template} instance for this presenter.
+ *
+ * <p>If the new template is of the same type as the one currently set, implementations should try
+ * to diff the data to apply a minimal view update when it would otherwise cause undesirable
+ * performance or visible UI artifacts. For example, when updating a list view, the diffing logic
+ * should detect which specific items changed and only update those rather than doing a full
+ * update of all items, which is important if using a {@link
+ * androidx.recyclerview.widget.RecyclerView} that may have special animations for the different
+ * adapter update operations.
+ */
+ void setTemplate(TemplateWrapper templateWrapper);
+
+ /** Returns the {@link Template} instance set in the template wrapper. */
+ Template getTemplate();
+
+ /** Returns the {@link TemplateWrapper} instance set in the presenter. */
+ TemplateWrapper getTemplateWrapper();
+
+ /** Returns the {@link TemplateContext} set in the presenter. */
+ TemplateContext getTemplateContext();
+
+ /**
+ * Returns the {@link View} instance representing the UI to display for the currently set {@link
+ * Template}.
+ */
+ View getView();
+
+ /** Applies the given {@code windowInsets} to the appropriate views. */
+ void applyWindowInsets(WindowInsets windowInsets, int minimumTopPadding);
+
+ /** Sets the default focus of the presenter's UI in the rotary or touchpad mode. */
+ boolean setDefaultFocus();
+
+ /**
+ * Called when a key event was received while the presenter is currently visible.
+ *
+ * @return {@code true} if the presenter handled the key event, otherwise {@code false}.
+ */
+ boolean onKeyUp(int keyCode, KeyEvent keyEvent);
+
+ /**
+ * Called when the view tree is about to be drawn. At this point, all views in the tree have been
+ * measured and given a frame. Presenters can use this to adjust their scroll bounds or even to
+ * request a new layout before drawing occurs.
+ */
+ boolean onPreDraw();
+
+ /**
+ * Notifies that the presenter instance has been created and its view is about to be added to the
+ * template view's hierarchy as the currently visible one.
+ *
+ * <p>Presenters can implement any initialization logic in here.
+ */
+ void onCreate();
+
+ /**
+ * Notifies that the presenter instance has been destroyed, and removed from the template view's
+ * hierarchy.
+ *
+ * <p>Presenters can implement any cleanup logic in here.
+ */
+ void onDestroy();
+
+ /**
+ * Notifies that the presenter is visible to the user.
+ *
+ * <p>Presenters can use method to implement any logic that was stopped during {@link #onStop}.
+ */
+ void onStart();
+
+ /**
+ * Notifies that the presenter is not visible to the user.
+ *
+ * <p>Presenters can use method to stop any logic that is not needed when the presenter is not
+ * visible, e.g. to conserve resources.
+ */
+ void onStop();
+
+ /** Notifies that the presenter is actively running. */
+ void onResume();
+
+ /** Notifies that the presenter is not actively running but still visible. */
+ void onPause();
+
+ /** Returns whether this presenter handles its own template change animations. */
+ boolean handlesTemplateChangeAnimation();
+
+ /**
+ * Returns whether this presenter is considered a full screen template.
+ *
+ * <p>Map and navigation templates are not full screen as they leave the space for map to be
+ * shown, and the UI elements only cover a smaller portion of the car screen.
+ */
+ boolean isFullScreen();
+
+ /**
+ * Returns whether this presenter uses the surface accessible via a {@link
+ * androidx.car.app.SurfaceContainer}.
+ */
+ boolean usesSurface();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterFactory.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterFactory.java
new file mode 100644
index 0000000..cdcbcd2
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterFactory.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import java.util.Collection;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** A provider of {@link TemplatePresenter} instances. */
+public interface TemplatePresenterFactory {
+
+ /**
+ * Returns a new instance of a {@link TemplatePresenter} for the given template or {@code null} if
+ * a presenter for the template type could not be found.
+ */
+ @Nullable TemplatePresenter createPresenter(
+ TemplateContext templateContext, TemplateWrapper template);
+
+ /** Returns the collection of templates this factory supports. */
+ Collection<Class<? extends Template>> getSupportedTemplates();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterRegistry.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterRegistry.java
new file mode 100644
index 0000000..b86e2c4
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterRegistry.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A registry of {@link TemplatePresenterFactory} instances.
+ *
+ * <p>It is implemented as a {@link TemplatePresenterFactory} that wraps N other factories.
+ */
+public class TemplatePresenterRegistry implements TemplatePresenterFactory {
+ private static final TemplatePresenterRegistry INSTANCE = new TemplatePresenterRegistry();
+
+ private final Map<Class<? extends Template>, TemplatePresenterFactory> mRegistry =
+ new HashMap<>();
+ private final Set<Class<? extends Template>> mSupportedTemplates = new HashSet<>();
+
+ /** Returns a singleton instance of the {@link TemplatePresenterRegistry}. */
+ public static TemplatePresenterRegistry get() {
+ return INSTANCE;
+ }
+
+ @Override
+ @Nullable
+ public TemplatePresenter createPresenter(
+ TemplateContext templateContext, TemplateWrapper templateWrapper) {
+
+ TemplatePresenterFactory factory = mRegistry.get(templateWrapper.getTemplate().getClass());
+ return factory == null ? null : factory.createPresenter(templateContext, templateWrapper);
+ }
+
+ @Override
+ public Collection<Class<? extends Template>> getSupportedTemplates() {
+ return Collections.unmodifiableCollection(mSupportedTemplates);
+ }
+
+ /** Registers the given {@link TemplatePresenterFactory}. */
+ public void register(TemplatePresenterFactory factory) {
+ for (Class<? extends Template> clazz : factory.getSupportedTemplates()) {
+ mRegistry.put(clazz, factory);
+ mSupportedTemplates.add(clazz);
+ }
+ }
+
+ /** Clears the registry of any registered factories. */
+ public void clear() {
+ mRegistry.clear();
+ mSupportedTemplates.clear();
+ }
+
+ private TemplatePresenterRegistry() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateTransitionManager.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateTransitionManager.java
new file mode 100644
index 0000000..2d241fb
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateTransitionManager.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view;
+
+import android.view.View;
+import android.view.ViewGroup;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Controls transitions between different presenters. */
+public interface TemplateTransitionManager {
+ /** Handles the transition between one template presenter and another. */
+ void transition(
+ ViewGroup root, View surface, TemplatePresenter to, @Nullable TemplatePresenter from);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ActionButtonListParams.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ActionButtonListParams.java
new file mode 100644
index 0000000..b6a5efa
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ActionButtonListParams.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.common;
+
+import android.graphics.Color;
+import androidx.annotation.ColorInt;
+
+/** Encapsulates parameters that configure the way action button list instances are rendered. */
+public class ActionButtonListParams {
+
+ private final int mMaxActions;
+ private final boolean mAllowOemReordering;
+ private final boolean mAllowOemColorOverride;
+ private final boolean mAllowAppColor;
+ @ColorInt private final int mSurroundingColor;
+
+ /** Returns a builder of {@link ActionButtonListParams}. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static Builder builder(ActionButtonListParams params) {
+ return new Builder()
+ .setMaxActions(params.getMaxActions())
+ .setOemReorderingAllowed(params.allowOemReordering())
+ .setOemColorOverrideAllowed(params.allowOemColorOverride())
+ .setAllowAppColor(params.allowAppColor())
+ .setSurroundingColor(params.getSurroundingColor());
+ }
+
+ /** Returns the maximum number of action buttons in the list. */
+ public int getMaxActions() {
+ return mMaxActions;
+ }
+ /**
+ * Returns the surrounding color against which the button will be displayed.
+ *
+ * <p>This color is used to compare the contrast between the surrounding color and the button
+ * background color.
+ *
+ * @see Builder#setSurroundingColor(int)
+ */
+ @ColorInt
+ public int getSurroundingColor() {
+ return mSurroundingColor;
+ }
+
+ /** Returns whether the button can have app-defined colors. */
+ public boolean allowAppColor() {
+ return mAllowAppColor;
+ }
+
+ /** Returns whether the buttons can be re-ordered by OEMs or not. */
+ public boolean allowOemReordering() {
+ return mAllowOemReordering;
+ }
+
+ /** Returns whether the button colors can be overridden by OEMs. */
+ public boolean allowOemColorOverride() {
+ return mAllowOemColorOverride;
+ }
+
+ private ActionButtonListParams(
+ int maxActions,
+ boolean allowOemReordering,
+ boolean allowOemColorOverride,
+ boolean allowAppColor,
+ @ColorInt int surroundingColor) {
+ mMaxActions = maxActions;
+ mAllowOemReordering = allowOemReordering;
+ mAllowOemColorOverride = allowOemColorOverride;
+ mAllowAppColor = allowAppColor;
+ mSurroundingColor = surroundingColor;
+ }
+
+ /** A builder of {@link ActionButtonListParams} instances. */
+ public static class Builder {
+ private int mMaxActions = 0;
+ private boolean mAllowOemReordering = false;
+ private boolean mAllowOemColorOverride = false;
+ private boolean mAllowAppColor = false;
+ @ColorInt private int mSurroundingColor = Color.TRANSPARENT;
+
+ /** Sets the maximum number of action buttons in the list. */
+ public Builder setMaxActions(int maxActions) {
+ mMaxActions = maxActions;
+ return this;
+ }
+
+ /** Sets whether the buttons can be re-ordered by OEMs or not. */
+ public Builder setOemReorderingAllowed(boolean allowOemReordering) {
+ mAllowOemReordering = allowOemReordering;
+ return this;
+ }
+
+ /** Sets whether the button colors can be overridden by OEMs. */
+ public Builder setOemColorOverrideAllowed(boolean allowOemColorOverride) {
+ mAllowOemColorOverride = allowOemColorOverride;
+ return this;
+ }
+
+ /** Sets whether the button can have app-defined colors. */
+ public Builder setAllowAppColor(boolean allowAppColor) {
+ mAllowAppColor = allowAppColor;
+ return this;
+ }
+
+ /**
+ * Sets the surrounding color against which the button will be displayed.
+ *
+ * <p>This color is used to compare the contrast between the surrounding color and the button
+ * background color.
+ *
+ * <p>By default, the surrounding color is assumed to be transparent.
+ */
+ public Builder setSurroundingColor(@ColorInt int surroundingColor) {
+ mSurroundingColor = surroundingColor;
+ return this;
+ }
+
+ /** Constructs a {@link ActionButtonListParams} instance defined by this builder. */
+ public ActionButtonListParams build() {
+ return new ActionButtonListParams(
+ mMaxActions,
+ mAllowOemReordering,
+ mAllowOemColorOverride,
+ mAllowAppColor,
+ mSurroundingColor);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextParams.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextParams.java
new file mode 100644
index 0000000..09bd11f
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextParams.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.common;
+
+import android.graphics.Color;
+import android.graphics.Rect;
+import androidx.annotation.ColorInt;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarColor;
+import com.android.car.libraries.apphost.distraction.constraints.CarColorConstraints;
+
+/** Encapsulates parameters that configure the way car text instances are rendered. */
+public class CarTextParams {
+ /** Default params which should be used for most text in all templates. */
+ public static final CarTextParams DEFAULT =
+ new CarTextParams(
+ /* colorSpanConstraints= */ CarColorConstraints.NO_COLOR,
+ /* allowClickableSpans= */ false,
+ /* imageBoundingBox= */ null,
+ /* maxImages= */ 0,
+ // No need to pass icon tint since no images are allowed.
+ /* defaultIconTint= */ Color.TRANSPARENT,
+ /* backgroundColor= */ Color.TRANSPARENT,
+ /* ignoreAppIconTint= */ false);
+
+ @Nullable private final Rect mImageBoundingBox;
+ private final int mMaxImages;
+ @ColorInt private final int mDefaultIconTint;
+ private final boolean mIgnoreAppIconTint;
+ private final CarColorConstraints mColorSpanConstraints;
+ private final boolean mAllowClickableSpans;
+ @ColorInt private final int mBackgroundColor;
+
+ /** Returns a builder of {@link CarTextParams}. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static Builder builder(CarTextParams params) {
+ return new Builder()
+ .setColorSpanConstraints(params.getColorSpanConstraints())
+ .setAllowClickableSpans(params.getAllowClickableSpans())
+ .setImageBoundingBox(params.getImageBoundingBox())
+ .setMaxImages(params.getMaxImages())
+ .setDefaultIconTint(params.getDefaultIconTint())
+ .setBackgroundColor(params.getBackgroundColor())
+ .setIgnoreAppIconTint(params.ignoreAppIconTint());
+ }
+
+ /**
+ * Returns the bounding box for a span image.
+ *
+ * <p>Images are scaled to fit within this bounding box.
+ */
+ @Nullable
+ Rect getImageBoundingBox() {
+ return mImageBoundingBox;
+ }
+
+ /** Returns the maximum number of image spans to allow in the text. */
+ int getMaxImages() {
+ return mMaxImages;
+ }
+
+ /** Returns the constraints on the color spans in the text. */
+ CarColorConstraints getColorSpanConstraints() {
+ return mColorSpanConstraints;
+ }
+
+ /** Returns whether clickable spans are allowed in the text. */
+ boolean getAllowClickableSpans() {
+ return mAllowClickableSpans;
+ }
+
+ /**
+ * Returns the default tint color to apply to the icon if one is not specified explicitly.
+ *
+ * @see Builder#setDefaultIconTint(int)
+ */
+ @ColorInt
+ int getDefaultIconTint() {
+ return mDefaultIconTint;
+ }
+
+ /** Returns whether the app-provided icon tint should be ignored. */
+ public boolean ignoreAppIconTint() {
+ return mIgnoreAppIconTint;
+ }
+
+ /**
+ * Returns the background color against which the text will be displayed.
+ *
+ * @see Builder#setBackgroundColor(int)
+ */
+ @ColorInt
+ int getBackgroundColor() {
+ return mBackgroundColor;
+ }
+
+ private CarTextParams(
+ CarColorConstraints colorSpanConstraints,
+ boolean allowClickableSpans,
+ @Nullable Rect imageBoundingBox,
+ int maxImages,
+ @ColorInt int defaultIconTint,
+ @ColorInt int backgroundColor,
+ boolean ignoreAppIconTint) {
+ mColorSpanConstraints = colorSpanConstraints;
+ mAllowClickableSpans = allowClickableSpans;
+ mImageBoundingBox = imageBoundingBox;
+ mMaxImages = maxImages;
+ mDefaultIconTint = defaultIconTint;
+ mBackgroundColor = backgroundColor;
+ mIgnoreAppIconTint = ignoreAppIconTint;
+ }
+
+ /** A builder of {@link CarTextParams} instances. */
+ public static class Builder {
+ private CarColorConstraints mColorSpanConstraints = CarColorConstraints.NO_COLOR;
+ private boolean mAllowClickableSpans;
+ @Nullable private Rect mImageBoundingBox;
+ private int mMaxImages;
+ @ColorInt private int mDefaultIconTint = Color.TRANSPARENT;
+ @ColorInt private int mBackgroundColor = Color.TRANSPARENT;
+ private boolean mIgnoreAppIconTint;
+
+ /**
+ * Sets the constraints on the color spans in the text.
+ *
+ * <p>By default, no color spans are allowed in the text.
+ *
+ * @see #getColorSpanConstraints()
+ */
+ public Builder setColorSpanConstraints(CarColorConstraints colorSpanConstraints) {
+ mColorSpanConstraints = colorSpanConstraints;
+ return this;
+ }
+
+ /**
+ * Sets whether clickable spans are allowed in the text.
+ *
+ * <p>By default, no clickable spans are allowed in the text.
+ *
+ * @see #getAllowClickableSpans()
+ */
+ public Builder setAllowClickableSpans(boolean allowClickableSpans) {
+ mAllowClickableSpans = allowClickableSpans;
+ return this;
+ }
+
+ /**
+ * Sets the bounding box for the image spans.
+ *
+ * <p>By default, no bounding box is specified.
+ *
+ * @see #getImageBoundingBox()
+ */
+ public Builder setImageBoundingBox(@Nullable Rect imageBoundingBox) {
+ mImageBoundingBox = imageBoundingBox;
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of image spans to allow for the text.
+ *
+ * <p>By default, no images are allowed in the text.
+ *
+ * @see #getMaxImages()
+ */
+ public Builder setMaxImages(int maxImages) {
+ mMaxImages = maxImages;
+ return this;
+ }
+
+ /**
+ * Sets the default tint to use for the images in the span that set their tint to {@link
+ * CarColor#DEFAULT}.
+ *
+ * <p>This tint may vary depending on where the spans are rendered, and can be specified here.
+ *
+ * <p>By default, this tint is transparent.
+ */
+ public Builder setDefaultIconTint(@ColorInt int defaultIconTint) {
+ mDefaultIconTint = defaultIconTint;
+ return this;
+ }
+
+ /** Determines if the app-provided icon tint should be ignored. */
+ public Builder setIgnoreAppIconTint(boolean ignoreAppIconTint) {
+ mIgnoreAppIconTint = ignoreAppIconTint;
+ return this;
+ }
+
+ /**
+ * Sets the background color against which the text will be displayed.
+ *
+ * <p>This color is used only for the color contrast check, and will not be applied on the text
+ * background.
+ *
+ * <p>By default, the background color is assumed to be transparent.
+ */
+ public Builder setBackgroundColor(@ColorInt int backgroundColor) {
+ mBackgroundColor = backgroundColor;
+ return this;
+ }
+
+ /** Constructs a {@link CarTextParams} instance defined by this builder. */
+ public CarTextParams build() {
+ if (mImageBoundingBox == null && mMaxImages > 0) {
+ throw new IllegalStateException(
+ "A bounding box needs to be provided if images are allowed in the text");
+ }
+
+ return new CarTextParams(
+ mColorSpanConstraints,
+ mAllowClickableSpans,
+ mImageBoundingBox,
+ mMaxImages,
+ mDefaultIconTint,
+ mBackgroundColor,
+ mIgnoreAppIconTint);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextUtils.java
new file mode 100644
index 0000000..dc3ab47
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextUtils.java
@@ -0,0 +1,484 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.common;
+
+import static androidx.car.app.model.CarIconSpan.ALIGN_BASELINE;
+import static androidx.car.app.model.CarIconSpan.ALIGN_BOTTOM;
+import static androidx.car.app.model.CarIconSpan.ALIGN_CENTER;
+import static com.android.car.libraries.apphost.view.common.ImageUtils.SCALE_CENTER_Y_INSIDE;
+import static com.android.car.libraries.apphost.view.common.ImageUtils.SCALE_INSIDE;
+import static java.util.Objects.requireNonNull;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ImageSpan;
+import android.view.View;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.car.app.model.CarColor;
+import androidx.car.app.model.CarIcon;
+import androidx.car.app.model.CarIconSpan;
+import androidx.car.app.model.CarSpan;
+import androidx.car.app.model.CarText;
+import androidx.car.app.model.ClickableSpan;
+import androidx.car.app.model.Distance;
+import androidx.car.app.model.DistanceSpan;
+import androidx.car.app.model.DurationSpan;
+import androidx.car.app.model.ForegroundCarColorSpan;
+import androidx.car.app.model.OnClickDelegate;
+import com.android.car.libraries.apphost.common.CarColorUtils;
+import com.android.car.libraries.apphost.common.CommonUtils;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.distraction.constraints.CarColorConstraints;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.view.common.ImageUtils.ScaleType;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Utilities for handling {@link CarText} instances. */
+public class CarTextUtils {
+ /**
+ * An internal flag that indicates that the main text should be converted instead of a variant.
+ */
+ private static final int USE_MAIN_TEXT = -1;
+
+ /**
+ * Returns {@code true} if there is enough color contrast between all {@link
+ * ForegroundCarColorSpan}s in the given {@code carText} and the given {@code backgroundColor},
+ * otherwise {@code false}.
+ */
+ public static boolean checkColorContrast(
+ TemplateContext templateContext, CarText carText, @ColorInt int backgroundColor) {
+ List<CharSequence> texts = new ArrayList<>();
+ texts.add(carText.toCharSequence());
+ texts.addAll(carText.getVariants());
+
+ for (CharSequence text : texts) {
+ if (text instanceof Spanned) {
+ Spanned spanned = (Spanned) text;
+
+ for (Object span : spanned.getSpans(0, spanned.length(), Object.class)) {
+ if (span instanceof ForegroundCarColorSpan) {
+ ForegroundCarColorSpan colorSpan = (ForegroundCarColorSpan) span;
+ CarColor foregroundCarColor = colorSpan.getColor();
+ if (!CarColorUtils.checkColorContrast(
+ templateContext, foregroundCarColor, backgroundColor)) {
+ return false;
+ }
+ }
+
+ if (span instanceof CarIconSpan) {
+ CarIconSpan carIconSpan = (CarIconSpan) span;
+ CarIcon icon = carIconSpan.getIcon();
+ if (icon != null) {
+ CarColor tint = icon.getTint();
+ if (tint != null
+ && !CarColorUtils.checkColorContrast(templateContext, tint, backgroundColor)) {
+ return false;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns a {@link CharSequence} from a {@link CarText} instance, with default {@link
+ * CarTextParams} that disallow images in text spans.
+ *
+ * @see #toCharSequenceOrEmpty(TemplateContext, CarText, CarTextParams)
+ */
+ public static CharSequence toCharSequenceOrEmpty(
+ TemplateContext templateContext, @Nullable CarText carText) {
+ return toCharSequenceOrEmpty(templateContext, carText, CarTextParams.DEFAULT);
+ }
+
+ /**
+ * Returns a {@link CharSequence} from a {@link CarText} instance, or an empty string if the input
+ * {@link CarText} is {@code null}.
+ */
+ public static CharSequence toCharSequenceOrEmpty(
+ TemplateContext templateContext, @Nullable CarText carText, CarTextParams params) {
+ return toCharSequenceOrEmpty(templateContext, carText, params, USE_MAIN_TEXT);
+ }
+
+ /**
+ * Returns a {@link CharSequence} from a {@link CarText} instance's variant at the given index, or
+ * an empty string if the input {@link CarText} is {@code null}.
+ *
+ * <p>if {@code variantIndex} is equal to {@link #USE_MAIN_TEXT}, the main text will be used.
+ */
+ public static CharSequence toCharSequenceOrEmpty(
+ TemplateContext templateContext,
+ @Nullable CarText carText,
+ CarTextParams params,
+ int variantIndex) {
+ CharSequence s = toCharSequence(templateContext, carText, params, variantIndex);
+ return s == null ? "" : s;
+ }
+
+ /**
+ * Reconstitutes a {@link CharSequence} from a {@link CarText} instance.
+ *
+ * <p>The client converts {@link CharSequence}s containing our custom car spans into {@link
+ * CarText}s that get marshaled to the host. These spans may contain standard images or icons in
+ * them. This method does the inverse conversion to generate char sequences that resolve the
+ * actual color resources to use when rendering the text.
+ */
+ @Nullable
+ private static CharSequence toCharSequence(
+ TemplateContext templateContext,
+ @Nullable CarText carText,
+ CarTextParams params,
+ int variantIndex) {
+ if (carText == null) {
+ return null;
+ }
+
+ CharSequence charSequence;
+ if (variantIndex == USE_MAIN_TEXT) {
+ charSequence = carText.toCharSequence();
+ } else {
+ List<CharSequence> variants = carText.getVariants();
+ if (variantIndex >= variants.size()) {
+ return null;
+ }
+ charSequence = variants.get(variantIndex);
+ }
+
+ if (!(charSequence instanceof Spanned)) {
+ // The API should always return a spanned, but in case it does not, we'll convert the
+ // char
+ // sequence to string and log a warning, to prevent an invalid cast exception that would
+ // crash the host.
+ L.w(LogTags.TEMPLATE, "Expecting spanned char sequence, will default to string");
+ return charSequence.toString();
+ }
+
+ Spanned spanned = (Spanned) charSequence;
+
+ // Separate style and replacement spans.
+ List<SpanWrapper> styleSpans = new ArrayList<>();
+ List<SpanWrapper> replacementSpans = new ArrayList<>();
+ for (Object span : spanned.getSpans(0, spanned.length(), Object.class)) {
+ if (span instanceof CarSpan) {
+ CarSpan carSpan = (CarSpan) span;
+ SpanWrapper wrapper =
+ new SpanWrapper(
+ carSpan,
+ spanned.getSpanStart(span),
+ spanned.getSpanEnd(span),
+ spanned.getSpanFlags(span));
+ if (carSpan instanceof DistanceSpan
+ || carSpan instanceof DurationSpan
+ || carSpan instanceof CarIconSpan) {
+ replacementSpans.add(wrapper);
+ } else if (carSpan instanceof ForegroundCarColorSpan || carSpan instanceof ClickableSpan) {
+ styleSpans.add(wrapper);
+ } else {
+ L.e(LogTags.TEMPLATE, "Ignoring non unsupported span type: %s", span);
+ }
+ } else {
+ L.e(LogTags.TEMPLATE, "Ignoring span not of CarSpan type: %s", span);
+ }
+ }
+
+ // Apply style spans first, and then the replacement spans, in order to apply the correct
+ // styling span range to the replacement texts.
+ SpannableStringBuilder sb = new SpannableStringBuilder(charSequence.toString());
+ setStyleSpans(templateContext, styleSpans, sb, params);
+ setReplacementSpans(templateContext, replacementSpans, sb, params);
+
+ return sb;
+ }
+
+ /**
+ * Sets the spans that change the text style.
+ *
+ * <p>Supports {@link ForegroundCarColorSpan}. Unsupported spans are ignored.
+ */
+ private static void setStyleSpans(
+ TemplateContext templateContext,
+ List<SpanWrapper> styleSpans,
+ SpannableStringBuilder sb,
+ CarTextParams params) {
+ final CarColorConstraints colorSpanConstraints = params.getColorSpanConstraints();
+ final boolean allowClickableSpans = params.getAllowClickableSpans();
+ for (SpanWrapper wrapper : styleSpans) {
+ if (wrapper.mCarSpan instanceof ForegroundCarColorSpan) {
+ if (colorSpanConstraints.equals(CarColorConstraints.NO_COLOR)) {
+ L.w(LogTags.TEMPLATE, "Color spans not allowed, dropping color: %s", wrapper);
+ } else {
+ setColorSpan(
+ templateContext,
+ wrapper,
+ sb,
+ (ForegroundCarColorSpan) wrapper.mCarSpan,
+ colorSpanConstraints,
+ params.getBackgroundColor());
+ }
+ } else if (wrapper.mCarSpan instanceof ClickableSpan) {
+ if (!allowClickableSpans) {
+ L.w(LogTags.TEMPLATE, "Clickable spans not allowed, dropping click listener");
+ } else {
+ setClickableSpan(templateContext, wrapper, sb, (ClickableSpan) wrapper.mCarSpan);
+ }
+ } else {
+ L.e(LogTags.TEMPLATE, "Ignoring unsupported span: %s", wrapper);
+ }
+ }
+ }
+
+ /**
+ * Sets the spans that replace the text.
+ *
+ * <p>Supported spans are:
+ *
+ * <ul>
+ * <li>{@link DistanceSpan}
+ * <li>{@link DurationSpan}
+ * <li>{@link CarIconSpan}
+ * </ul>
+ *
+ * Unsupported spans are ignored.
+ *
+ * <p>Only spans that do not overlap with any other replacement spans will be applied.
+ */
+ private static void setReplacementSpans(
+ TemplateContext templateContext,
+ List<SpanWrapper> replacementSpans,
+ SpannableStringBuilder sb,
+ CarTextParams params) {
+ // Only apply disjoint spans.
+ List<SpanWrapper> spans = new ArrayList<>();
+ for (SpanWrapper wrapper : replacementSpans) {
+ if (isDisjoint(wrapper, replacementSpans)) {
+ spans.add(wrapper);
+ }
+ }
+
+ // Apply replacement spans from right to left.
+ Collections.sort(spans, (s1, s2) -> s2.mStart - s1.mStart);
+ final int maxImages = params.getMaxImages();
+ int imageCount = 0;
+ for (SpanWrapper wrapper : spans) {
+ CarSpan span = wrapper.mCarSpan;
+ if (span instanceof DistanceSpan) {
+ Distance distance = ((DistanceSpan) span).getDistance();
+ if (distance == null) {
+ L.w(LogTags.TEMPLATE, "Distance span is missing its distance: %s", span);
+ } else {
+ String distanceText =
+ DistanceUtils.convertDistanceToDisplayString(templateContext, distance);
+ sb.replace(wrapper.mStart, wrapper.mEnd, distanceText);
+ }
+ } else if (span instanceof DurationSpan) {
+ DurationSpan durationSpan = (DurationSpan) span;
+ String durationText =
+ DateTimeUtils.formatDurationString(
+ templateContext, Duration.ofSeconds(durationSpan.getDurationSeconds()));
+ sb.replace(wrapper.mStart, wrapper.mEnd, durationText);
+ } else if (span instanceof CarIconSpan) {
+ if (++imageCount > maxImages) {
+ L.w(LogTags.TEMPLATE, "Span over max image count, dropping image: %s", span);
+ } else {
+ setImageSpan(templateContext, params, wrapper, sb, (CarIconSpan) span);
+ }
+ } else {
+ L.e(
+ LogTags.TEMPLATE,
+ "Ignoring unsupported span found of type: %s",
+ span.getClass().getCanonicalName());
+ }
+ }
+ }
+
+ private static boolean isDisjoint(SpanWrapper wrapper, List<SpanWrapper> spans) {
+ for (SpanWrapper otherWrapper : spans) {
+ if (wrapper.equals(otherWrapper)) {
+ continue;
+ }
+
+ if (wrapper.mStart < otherWrapper.mEnd && wrapper.mEnd > otherWrapper.mStart) {
+ // The wrapper overlaps with the other wrapper.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static void setImageSpan(
+ TemplateContext templateContext,
+ CarTextParams params,
+ SpanWrapper wrapper,
+ SpannableStringBuilder sb,
+ CarIconSpan carIconSpan) {
+ L.d(LogTags.TEMPLATE, "Converting car image: %s", wrapper);
+
+ Rect boundingBox = requireNonNull(params.getImageBoundingBox());
+
+ // Get the desired alignment for span coming from the app.
+ int alignment = carIconSpan.getAlignment();
+ if (alignment != ALIGN_BASELINE && alignment != ALIGN_BOTTOM && alignment != ALIGN_CENTER) {
+ L.e(LogTags.TEMPLATE, "Invalid alignment value, will default to baseline");
+ alignment = ALIGN_BASELINE;
+ }
+
+ // Determine how to scale the span image.
+ @ScaleType int scaleType;
+ int spanAlignment;
+ switch (alignment) {
+ case ALIGN_BOTTOM:
+ spanAlignment = ImageSpan.ALIGN_BOTTOM;
+ scaleType = SCALE_INSIDE;
+ break;
+ case ALIGN_CENTER:
+ // API 29 introduces a native ALIGN_BOTTOM ImageSpan option, but in order to supoprt
+ // APIs down to our minimum, we implement center alignment by using a
+ // center_y_inside
+ // scale type. This makes the icon be center aligned with the bounding box on the Y
+ // axis. Since our bounding boxes are configured to match the height of a line of
+ // text,
+ // makes the icon display as center aligned.
+ spanAlignment = ImageSpan.ALIGN_BOTTOM;
+ scaleType = SCALE_CENTER_Y_INSIDE;
+ break;
+ case ALIGN_BASELINE: // fall-through
+ default:
+ spanAlignment = ImageSpan.ALIGN_BASELINE;
+ scaleType = SCALE_INSIDE;
+ break;
+ }
+
+ CarIcon icon = carIconSpan.getIcon();
+ if (icon == null) {
+ L.e(LogTags.TEMPLATE, "Icon span doesn't contain an icon");
+ return;
+ }
+
+ ImageViewParams imageParams =
+ ImageViewParams.builder()
+ .setDefaultTint(params.getDefaultIconTint())
+ .setBackgroundColor(params.getBackgroundColor())
+ .setIgnoreAppTint(params.ignoreAppIconTint())
+ .build();
+ Bitmap bitmap =
+ ImageUtils.getBitmapFromIcon(
+ templateContext,
+ icon,
+ boundingBox.width(),
+ boundingBox.height(),
+ imageParams,
+ scaleType);
+ if (bitmap == null) {
+ L.e(LogTags.TEMPLATE, "Failed to get bitmap for icon span");
+ } else {
+ sb.setSpan(
+ new ImageSpan(templateContext, bitmap, spanAlignment),
+ wrapper.mStart,
+ wrapper.mEnd,
+ wrapper.mFlags);
+ }
+ }
+
+ private static void setColorSpan(
+ TemplateContext templateContext,
+ SpanWrapper wrapper,
+ SpannableStringBuilder sb,
+ ForegroundCarColorSpan carColorSpan,
+ CarColorConstraints colorSpanConstraints,
+ @ColorInt int backgroundColor) {
+ L.d(LogTags.TEMPLATE, "Converting foreground color span: %s", wrapper);
+
+ @ColorInt
+ int color =
+ CarColorUtils.resolveColor(
+ templateContext,
+ carColorSpan.getColor(),
+ /* isDark= */ false,
+ /* defaultColor= */ Color.WHITE,
+ colorSpanConstraints,
+ backgroundColor);
+ if (color == Color.WHITE) {
+ // If the ForegroundCarColoSpan is of the default color, we do not need to create a span
+ // as the view will just use its default color to render.
+ return;
+ }
+
+ try {
+ sb.setSpan(new ForegroundColorSpan(color), wrapper.mStart, wrapper.mEnd, wrapper.mFlags);
+ } catch (RuntimeException e) {
+ L.e(LogTags.TEMPLATE, e, "Failed to create foreground color span: %s", wrapper);
+ }
+ }
+
+ private static void setClickableSpan(
+ TemplateContext templateContext,
+ SpanWrapper wrapper,
+ SpannableStringBuilder sb,
+ ClickableSpan clickableSpan) {
+ L.d(LogTags.TEMPLATE, "Converting clickable span: %s", wrapper);
+
+ OnClickDelegate onClickDelegate = clickableSpan.getOnClickDelegate();
+ android.text.style.ClickableSpan span =
+ new android.text.style.ClickableSpan() {
+ @Override
+ public void onClick(@NonNull View widget) {
+ CommonUtils.dispatchClick(templateContext, onClickDelegate);
+ }
+ };
+
+ try {
+ sb.setSpan(span, wrapper.mStart, wrapper.mEnd, wrapper.mFlags);
+ } catch (RuntimeException e) {
+ L.e(LogTags.TEMPLATE, e, "Failed to create clickable span: %s", wrapper);
+ }
+ }
+
+ /** A simple convenient structure to contain a span with its associated metadata. */
+ private static class SpanWrapper {
+ CarSpan mCarSpan;
+ int mStart;
+ int mEnd;
+ int mFlags;
+
+ SpanWrapper(CarSpan carSpan, int start, int end, int flags) {
+ mCarSpan = carSpan;
+ mStart = start;
+ mEnd = end;
+ mFlags = flags;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "[" + mCarSpan + ": " + mStart + ", " + mEnd + ", flags: " + mFlags + "]";
+ }
+ }
+
+ private CarTextUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DateTimeUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DateTimeUtils.java
new file mode 100644
index 0000000..60fc021
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DateTimeUtils.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.common;
+
+import android.text.TextUtils;
+import androidx.annotation.NonNull;
+import androidx.car.app.model.DateTimeWithZone;
+import com.android.car.libraries.apphost.common.HostResourceIds;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import java.text.DateFormat;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.TimeZone;
+
+/** Utilities for formatting and manipulating dates and times. */
+@SuppressWarnings("NewApi") // java.time APIs used throughout are OK through de-sugaring.
+public class DateTimeUtils {
+
+ /** Returns a string from a duration in order to display it in the UI. */
+ public static String formatDurationString(TemplateContext context, Duration duration) {
+ long days = duration.toDays();
+ long hours = duration.minusDays(days).toHours();
+ long minutes = duration.minusDays(days).minusHours(hours).toMinutes();
+ HostResourceIds resIds = context.getHostResourceIds();
+
+ String result = "";
+ if (days > 0) {
+ if (hours == 0) {
+ result = context.getString(resIds.getDurationInDaysStringFormat(), days);
+ } else {
+ result = context.getString(resIds.getDurationInDaysAndHoursStringFormat(), days, hours);
+ }
+ } else if (hours > 0) {
+ if (minutes == 0) {
+ result = context.getString(resIds.getDurationInHoursStringFormat(), hours);
+ } else {
+ result =
+ context.getString(resIds.getDurationInHoursAndMinutesStringFormat(), hours, minutes);
+ }
+ } else {
+ result = context.getString(resIds.getDurationInMinutesStringFormat(), minutes);
+ }
+
+ return result;
+ }
+
+ /**
+ * Returns a string to display in the UI from an arrival time at a destination that may be in a
+ * different time zone than the one given by {@code currentZoneId).
+ *
+ * <p>If the time zone offset at the destination is not the same as the current time zone, an
+ * abbreviated time zone string is added, for example "5:38 PM PST".
+ */
+ public static String formatArrivalTimeString(
+ @NonNull TemplateContext context,
+ @NonNull DateTimeWithZone timeAtDestination,
+ @NonNull ZoneId currentZoneId) {
+ // Get the offsets for the current and destination time zones.
+ long destinationTimeUtcMillis = timeAtDestination.getTimeSinceEpochMillis();
+
+ int currentOffsetSeconds =
+ currentZoneId
+ .getRules()
+ .getOffset(Instant.ofEpochMilli(destinationTimeUtcMillis))
+ .getTotalSeconds();
+ int destinationOffsetSeconds = timeAtDestination.getZoneOffsetSeconds();
+
+ DateFormat dateFormat = android.text.format.DateFormat.getTimeFormat(context);
+
+ if (currentOffsetSeconds == destinationOffsetSeconds) {
+ // The destination is in the same time zone, so we don't need to display the time zone
+ // string.
+ dateFormat.setTimeZone(TimeZone.getTimeZone(currentZoneId));
+ return dateFormat.format(destinationTimeUtcMillis);
+ } else {
+ // The destination is in a different timezone: calculate its zone offset and use it to
+ // format
+ // the time.
+ TimeZone destinationZone;
+ try {
+ destinationZone = TimeZone.getTimeZone(ZoneOffset.ofTotalSeconds(destinationOffsetSeconds));
+ } catch (DateTimeException e) {
+ // This should never happen as the client library has checks to prevent this.
+ L.e(LogTags.TEMPLATE, e, "Failed to get destination time zone, will use system default");
+ destinationZone = TimeZone.getDefault();
+ }
+
+ dateFormat.setTimeZone(destinationZone);
+ String timeAtDestinationString = dateFormat.format(destinationTimeUtcMillis);
+ String zoneShortName = timeAtDestination.getZoneShortName();
+
+ if (TextUtils.isEmpty(zoneShortName)) {
+ // This should never really happen, the client library has checks to enforce a non
+ // empty
+ // zone name.
+ L.w(LogTags.TEMPLATE, "Time zone name is empty when formatting date time");
+ return timeAtDestinationString;
+ } else {
+ return context
+ .getResources()
+ .getString(
+ context.getHostResourceIds().getTimeAtDestinationWithTimeZoneStringFormat(),
+ timeAtDestinationString,
+ zoneShortName);
+ }
+ }
+ }
+
+ private DateTimeUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DistanceUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DistanceUtils.java
new file mode 100644
index 0000000..b93b285
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DistanceUtils.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.common;
+
+import static androidx.car.app.model.Distance.UNIT_FEET;
+import static androidx.car.app.model.Distance.UNIT_KILOMETERS;
+import static androidx.car.app.model.Distance.UNIT_KILOMETERS_P1;
+import static androidx.car.app.model.Distance.UNIT_METERS;
+import static androidx.car.app.model.Distance.UNIT_MILES;
+import static androidx.car.app.model.Distance.UNIT_MILES_P1;
+import static androidx.car.app.model.Distance.UNIT_YARDS;
+
+import androidx.car.app.model.Distance;
+import com.android.car.libraries.apphost.common.HostResourceIds;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import java.text.DecimalFormat;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/** Utilities for handling {@link Distance} instances. */
+public class DistanceUtils {
+
+ private static final DecimalFormat FORMAT_OPTIONAL_TENTH = new DecimalFormat("#0.#");
+ private static final DecimalFormat FORMAT_MANDATORY_TENTH = new DecimalFormat("#0.0");
+
+ /** Converts a {@link Distance} to a display string for the UI. */
+ @NonNull
+ public static String convertDistanceToDisplayString(
+ @NonNull TemplateContext context, @NonNull Distance distance) {
+ int displayUnit = distance.getDisplayUnit();
+ HostResourceIds resIds = context.getHostResourceIds();
+
+ String formattedDistance = convertDistanceToDisplayStringNoUnit(context, distance);
+ switch (displayUnit) {
+ case UNIT_METERS:
+ return context.getString(resIds.getDistanceInMetersStringFormat(), formattedDistance);
+ case UNIT_KILOMETERS:
+ case UNIT_KILOMETERS_P1:
+ return context.getString(resIds.getDistanceInKilometersStringFormat(), formattedDistance);
+ case UNIT_FEET:
+ return context.getString(resIds.getDistanceInFeetStringFormat(), formattedDistance);
+ case UNIT_MILES:
+ case UNIT_MILES_P1:
+ return context.getString(resIds.getDistanceInMilesStringFormat(), formattedDistance);
+ case UNIT_YARDS:
+ return context.getString(resIds.getDistanceInYardsStringFormat(), formattedDistance);
+ default:
+ throw new UnsupportedOperationException("Unsupported distance unit type: " + displayUnit);
+ }
+ }
+
+ /** Converts a {@link Distance} to a display string without units. */
+ @NonNull
+ public static String convertDistanceToDisplayStringNoUnit(
+ @NonNull TemplateContext context, @NonNull Distance distance) {
+ int displayUnit = distance.getDisplayUnit();
+ double displayDistance = distance.getDisplayDistance();
+ DecimalFormat format =
+ (displayUnit == Distance.UNIT_KILOMETERS_P1 || displayUnit == Distance.UNIT_MILES_P1)
+ ? FORMAT_MANDATORY_TENTH
+ : FORMAT_OPTIONAL_TENTH;
+ return format.format(displayDistance);
+ }
+
+ /** Converts {@link Distance} to meters. */
+ public static int getMeters(Distance distance) {
+ int displayUnit = distance.getDisplayUnit();
+ switch (displayUnit) {
+ case UNIT_METERS:
+ return (int) Math.round(distance.getDisplayDistance());
+ case UNIT_KILOMETERS:
+ case UNIT_KILOMETERS_P1:
+ return (int) Math.round(distance.getDisplayDistance() * 1000.0d);
+ case UNIT_FEET:
+ return (int) Math.round(distance.getDisplayDistance() * 0.3048d);
+ case UNIT_MILES:
+ case UNIT_MILES_P1:
+ return (int) Math.round(distance.getDisplayDistance() * 1609.34d);
+ case UNIT_YARDS:
+ return (int) Math.round(distance.getDisplayDistance() * 0.9144d);
+ default:
+ throw new UnsupportedOperationException("Unsupported distance unit type: " + displayUnit);
+ }
+ }
+
+ private DistanceUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageUtils.java
new file mode 100644
index 0000000..c51bea5
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageUtils.java
@@ -0,0 +1,648 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.common;
+
+import static android.graphics.Color.TRANSPARENT;
+
+import static androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE;
+import static androidx.core.graphics.drawable.IconCompat.TYPE_URI;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.widget.ImageView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.Action;
+import androidx.car.app.model.CarColor;
+import androidx.car.app.model.CarIcon;
+import androidx.core.graphics.drawable.DrawableCompat;
+import androidx.core.graphics.drawable.IconCompat;
+
+import com.android.car.libraries.apphost.common.CarColorUtils;
+import com.android.car.libraries.apphost.common.HostResourceIds;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.distraction.constraints.CarColorConstraints;
+import com.android.car.libraries.apphost.distraction.constraints.CarIconConstraints;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.view.common.ImageViewParams.ImageLoadCallback;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.RequestBuilder;
+import com.bumptech.glide.load.DataSource;
+import com.bumptech.glide.load.engine.GlideException;
+import com.bumptech.glide.request.RequestListener;
+import com.bumptech.glide.request.target.CustomTarget;
+import com.bumptech.glide.request.target.Target;
+import com.bumptech.glide.request.transition.Transition;
+
+import java.util.function.Consumer;
+
+/** Assorted image utilities. */
+public final class ImageUtils {
+
+ /** Represents different ways of scaling bitmaps. */
+ @IntDef(
+ value = {
+ SCALE_FIT_CENTER,
+ SCALE_CENTER_Y_INSIDE,
+ SCALE_INSIDE,
+ SCALE_CENTER_XY_INSIDE,
+ })
+ public @interface ScaleType {}
+
+ /**
+ * Scales an image so that it fits centered within a bounding box, while maintaining its aspect
+ * ratio, and ensuring that at least one of the axis will match exactly the size of the bounding
+ * box. This means images may be down-scaled or up-scaled. The smaller dimension of the image
+ * will be centered within the bounding box.
+ */
+ @ScaleType public static final int SCALE_FIT_CENTER = 0;
+
+ /**
+ * This scale type is similar to {@link #SCALE_INSIDE} with the difference that the resulting
+ * bitmap will always have a height equals to the bounding box's, and the image will be drawn
+ * center-aligned vertically if smaller than the bounding box height, with the space at either
+ * side padded with transparent pixels.
+ */
+ @ScaleType public static final int SCALE_CENTER_Y_INSIDE = 1;
+
+ /**
+ * Scales an image so that it fits within a bounding box, while maintaining its aspect ratio,
+ * but images smaller than the bounding box do not get up-scaled.
+ */
+ @ScaleType public static final int SCALE_INSIDE = 2;
+
+ /**
+ * Similar to {@link #SCALE_FIT_CENTER} but the resulting bitmap never be up-scaled, only
+ * down-scaled (if needed).
+ */
+ @ScaleType public static final int SCALE_CENTER_XY_INSIDE = 3;
+
+ // Suppressing nullness check because AndroidX @Nullable can't be used to annotate generic types
+ @SuppressWarnings("nullness:argument")
+ private static class ImageTarget extends CustomTarget<Drawable> {
+ private final Consumer<Drawable> mImageTarget;
+
+ ImageTarget(int width, int height, Consumer<Drawable> imageTarget) {
+ super(width, height);
+ this.mImageTarget = imageTarget;
+ }
+
+ @Override
+ public void onLoadFailed(@Nullable Drawable errorDrawable) {
+ mImageTarget.accept(errorDrawable);
+ }
+
+ @Override
+ public void onResourceReady(
+ @NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
+ mImageTarget.accept(resource);
+ }
+
+ @Override
+ public void onLoadCleared(@Nullable Drawable placeholder) {
+ mImageTarget.accept(placeholder);
+ }
+ }
+
+ /** Sets the image source in an {@link ImageView} from a {@link CarIcon}. */
+ public static boolean setImageSrc(
+ TemplateContext templateContext,
+ @Nullable CarIcon carIcon,
+ ImageView imageView,
+ ImageViewParams viewParams) {
+ if (carIcon == null) {
+ L.e(LogTags.TEMPLATE, "Failed to load image from a null icon");
+ return false;
+ }
+
+ try {
+ viewParams.getConstraints().validateOrThrow(carIcon);
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ L.e(LogTags.TEMPLATE, e, "Failed to load image from an invalid icon: %s", carIcon);
+ return false;
+ }
+
+ int type = carIcon.getType();
+
+ // If the icon is custom, check that it is of a supported type.
+ if (type == CarIcon.TYPE_CUSTOM) {
+ IconCompat iconCompat = carIcon.getIcon();
+
+ if (iconCompat == null) {
+ L.e(LogTags.TEMPLATE, "Failed to get a valid backing icon for: %s", carIcon);
+ return setImageDrawable(imageView, null);
+ } else if (iconCompat.getType() == TYPE_URI) { // a custom icon of type URI.
+ return setImageSrcFromUri(
+ templateContext,
+ iconCompat.getUri(),
+ imageView,
+ carIcon.getTint(),
+ viewParams);
+ } else { // a custom icon not of type URI.
+ return setImageDrawable(
+ imageView, getIconDrawable(templateContext, carIcon, viewParams));
+ }
+ }
+
+ // a standard icon
+ return setImageDrawable(imageView, getIconDrawable(templateContext, carIcon, viewParams));
+ }
+
+ /** Sets the image source in an {@link Consumer<Drawable>} from a {@link CarIcon}. */
+ // TODO(b/183990524): See if this method could be unified with setImageSrc()
+ // Suppressing nullness check because AndroidX @Nullable can't be used to annotate generic types
+ // (see imageTarget parameter)
+ @SuppressWarnings("nullness:argument")
+ public static boolean setImageTargetSrc(
+ TemplateContext templateContext,
+ @Nullable CarIcon carIcon,
+ Consumer<Drawable> imageTarget,
+ ImageViewParams viewParams,
+ int width,
+ int height) {
+ if (carIcon == null) {
+ L.e(LogTags.TEMPLATE, "Failed to load image from a null icon");
+ return false;
+ }
+
+ try {
+ viewParams.getConstraints().validateOrThrow(carIcon);
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ L.e(LogTags.TEMPLATE, e, "Failed to load image from an invalid icon: %s", carIcon);
+ return false;
+ }
+
+ int type = carIcon.getType();
+
+ // If the icon is custom, check that it is of a supported type.
+ if (type == CarIcon.TYPE_CUSTOM) {
+ IconCompat iconCompat = carIcon.getIcon();
+
+ if (iconCompat == null) {
+ L.e(LogTags.TEMPLATE, "Failed to get a valid backing icon for: %s", carIcon);
+ imageTarget.accept(null);
+ return false;
+ } else if (iconCompat.getType() == TYPE_URI) { // a custom icon of type URI.
+ getRequestFromUri(
+ templateContext, iconCompat.getUri(), carIcon.getTint(), viewParams)
+ .into(new ImageTarget(width, height, imageTarget));
+ return true;
+ } else { // a custom icon not of type URI.
+ imageTarget.accept(getIconDrawable(templateContext, carIcon, viewParams));
+ return true;
+ }
+ }
+
+ // a standard icon
+ imageTarget.accept(getIconDrawable(templateContext, carIcon, viewParams));
+ return true;
+ }
+
+ /**
+ * Returns a bitmap containing the given {@link IconCompat}.
+ *
+ * <p>This method cannot be used for icons of type URI which require asynchronous loading.
+ */
+ @Nullable
+ public static Bitmap getBitmapFromIcon(
+ TemplateContext templateContext,
+ CarIcon icon,
+ int targetWidth,
+ int targetHeight,
+ ImageViewParams viewParams,
+ @ScaleType int scaleType) {
+ Drawable drawable = getIconDrawable(templateContext, icon, viewParams);
+ return drawable == null
+ ? null
+ : getBitmapFromDrawable(
+ drawable,
+ targetWidth,
+ targetHeight,
+ templateContext.getResources().getDisplayMetrics().densityDpi,
+ scaleType);
+ }
+
+ /** Returns a bitmap containing the given label using the given paint. */
+ public static Bitmap getBitmapFromString(String label, Paint textPaint) {
+ Rect bounds = new Rect();
+ textPaint.getTextBounds(label, 0, label.length(), bounds);
+
+ // TODO(b/149182818): robolectric always returns empty bound. Bypass with a 1x1 bitmap.
+ // See https://github.com/robolectric/robolectric/issues/4343 for public bug.
+ if (bounds.width() <= 0 || bounds.height() <= 0) {
+ bounds.set(0, 0, 1, 1);
+ }
+
+ Bitmap bitmap = Bitmap.createBitmap(bounds.width(), bounds.height(), Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ canvas.drawText(
+ label,
+ bounds.width() / 2.f,
+ bounds.height() / 2.f - (textPaint.descent() + textPaint.ascent()) / 2.f,
+ textPaint);
+ return bitmap;
+ }
+
+ /**
+ * Converts the {@code drawable} to a {@link Bitmap}.
+ *
+ * <p>The output {@link Bitmap} will be scaled to the input {@code targetWidth} and {@code
+ * targetHeight} if the drawable's size does not match up.
+ */
+ public static Bitmap getBitmapFromDrawable(
+ Drawable drawable, int maxWidth, int maxHeight, int density, @ScaleType int scaleType) {
+ int width = drawable.getIntrinsicWidth();
+ int height = drawable.getIntrinsicHeight();
+
+ float widthScale = ((float) maxWidth) / width;
+ float heightScale = ((float) maxHeight) / height;
+
+ float scale = Math.min(widthScale, heightScale);
+
+ if (scaleType == SCALE_INSIDE
+ || scaleType == SCALE_CENTER_Y_INSIDE
+ || scaleType == SCALE_CENTER_XY_INSIDE) {
+ // Scale down if necessary. Do not scale up.
+ scale = Math.min(1.f, scale);
+ }
+
+ int scaledWidth = (int) (width * scale);
+ int scaledHeight = (int) (height * scale);
+
+ int bitmapWidth = scaledWidth;
+ int bitmapHeight = scaledHeight;
+ switch (scaleType) {
+ case SCALE_FIT_CENTER:
+ case SCALE_CENTER_XY_INSIDE:
+ bitmapWidth = maxWidth;
+ bitmapHeight = maxHeight;
+ break;
+ case SCALE_CENTER_Y_INSIDE:
+ bitmapHeight = maxHeight;
+ break;
+ case SCALE_INSIDE:
+ default:
+ break;
+ }
+
+ Bitmap bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Config.ARGB_8888);
+ bitmap.setDensity(density);
+ Canvas canvas = new Canvas(bitmap);
+
+ float dx = 0;
+ float dy = 0;
+ // Center-align the image horizontally/vertically if we have to.
+ switch (scaleType) {
+ case SCALE_FIT_CENTER:
+ case SCALE_CENTER_XY_INSIDE:
+ dx = Math.max(0.f, (maxWidth - scaledWidth) / 2.f);
+ dy = Math.max(0.f, (maxHeight - scaledHeight) / 2.f);
+ break;
+ case SCALE_CENTER_Y_INSIDE:
+ dy = Math.max(0.f, (maxHeight - scaledHeight) / 2.f);
+ break;
+ case SCALE_INSIDE:
+ default:
+ break;
+ }
+ canvas.translate(dx, dy);
+ canvas.scale(scale, scale);
+ drawable.setFilterBitmap(true);
+ drawable.setBounds(0, 0, width, height);
+ drawable.draw(canvas);
+ return bitmap;
+ }
+
+ @DrawableRes
+ @VisibleForTesting
+ static int drawableIdFromCarIconType(int type, HostResourceIds hostResourceIds) {
+ switch (type) {
+ case CarIcon.TYPE_ALERT:
+ return hostResourceIds.getAlertIconDrawable();
+ case CarIcon.TYPE_ERROR:
+ return hostResourceIds.getErrorIconDrawable();
+ case CarIcon.TYPE_BACK:
+ return hostResourceIds.getBackIconDrawable();
+ case CarIcon.TYPE_PAN:
+ return hostResourceIds.getPanIconDrawable();
+ case CarIcon.TYPE_APP_ICON:
+ case CarIcon.TYPE_CUSTOM:
+ default:
+ L.w(LogTags.TEMPLATE, "Can't find drawable for icon type: %d", type);
+ return 0;
+ }
+ }
+
+ /** Returns the {@link CarIcon} that should be used for an {@link Action}. */
+ @Nullable
+ public static CarIcon getIconFromAction(Action action) {
+ CarIcon icon = action.getIcon();
+ if (icon == null && action.isStandard()) {
+ int type = action.getType();
+ icon = ImageUtils.getIconForStandardAction(type);
+ if (icon == null) {
+ L.e(LogTags.TEMPLATE, "Failed to get icon for standard action: %s", action);
+ }
+ }
+
+ return icon;
+ }
+
+ /** Returns the {@link CarIcon} corresponding to an action type. */
+ @Nullable
+ private static CarIcon getIconForStandardAction(int type) {
+ switch (type) {
+ case Action.TYPE_APP_ICON:
+ return CarIcon.APP_ICON;
+ case Action.TYPE_BACK:
+ return CarIcon.BACK;
+ case Action.TYPE_PAN:
+ return CarIcon.PAN;
+ case Action.TYPE_CUSTOM:
+ default:
+ L.e(LogTags.TEMPLATE, "Not a standard action: %s", type);
+ return null;
+ }
+ }
+
+ /**
+ * Sets the drawable to the image view.
+ *
+ * <p>Returns {@code true} if the view sets an image, and {@code false} if it clears the image
+ * (by setting a {@code null} drawable).
+ */
+ private static boolean setImageDrawable(ImageView imageView, @Nullable Drawable drawable) {
+ imageView.setImageDrawable(drawable);
+ return drawable != null;
+ }
+
+ private static boolean setImageSrcFromUri(
+ TemplateContext templateContext,
+ Uri uri,
+ ImageView imageView,
+ @Nullable CarColor tint,
+ ImageViewParams viewParams) {
+ getRequestFromUri(templateContext, uri, tint, viewParams).into(imageView);
+ return true;
+ }
+
+ private static RequestBuilder<Drawable> getRequestFromUri(
+ TemplateContext templateContext,
+ Uri uri,
+ @Nullable CarColor tint,
+ ImageViewParams viewParams) {
+ return Glide.with(templateContext)
+ .load(uri)
+ .placeholder(viewParams.getPlaceholderDrawable())
+ .listener(
+ new RequestListener<Drawable>() {
+ @Override
+ public boolean onLoadFailed(
+ @Nullable GlideException e,
+ Object model,
+ Target<Drawable> target,
+ boolean isFirstResource) {
+ ImageLoadCallback callback = viewParams.getImageLoadCallback();
+ if (callback != null) {
+ callback.onLoadFailed(e);
+ } else {
+ L.e(
+ LogTags.TEMPLATE,
+ e,
+ "Failed to load the image for URI: %s",
+ uri);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onResourceReady(
+ Drawable resource,
+ Object model,
+ Target<Drawable> target,
+ DataSource dataSource,
+ boolean isFirstResource) {
+ // If tint is specified in the icon, overwrite the backing icon's
+ // tint.
+ @ColorInt
+ int tintInt = getTintForIcon(templateContext, tint, viewParams);
+ if (tintInt != TRANSPARENT) {
+ resource.mutate();
+ resource.setTint(tintInt);
+ resource.setTintMode(Mode.SRC_IN);
+ }
+
+ ImageLoadCallback callback = viewParams.getImageLoadCallback();
+ if (callback != null) {
+ // TODO(b/156279162): Consider transition from placeholder image
+ target.onResourceReady(resource, /* transition= */ null);
+ callback.onImageReady();
+ return true;
+ }
+ return false;
+ }
+ });
+ }
+
+ /**
+ * Returns the tint to use for a given {@link CarColor} tint, or {@link Color#TRANSPARENT} if
+ * not tint should be applied.
+ */
+ @ColorInt
+ private static int getTintForIcon(
+ TemplateContext templateContext, @Nullable CarColor tint, ImageViewParams params) {
+ @ColorInt int defaultTint = params.getDefaultTint();
+ boolean forceTinting = params.getForceTinting();
+ boolean isDark = params.getIsDark();
+
+ if (tint != null && params.ignoreAppTint()) {
+ tint = CarColor.DEFAULT;
+ }
+
+ if (tint != null || forceTinting) {
+ return CarColorUtils.resolveColor(
+ templateContext,
+ tint,
+ isDark,
+ defaultTint,
+ CarColorConstraints.UNCONSTRAINED,
+ params.getBackgroundColor());
+ }
+ return TRANSPARENT;
+ }
+
+ /**
+ * Returns a drawable for a {@link CarIcon}.
+ *
+ * <p>This method should not be used for icons of type URI.
+ *
+ * @return {@code null} if it failed to get the icon, or if the icon type is a URI.
+ */
+ @Nullable
+ public static Drawable getIconDrawable(
+ TemplateContext templateContext, CarIcon carIcon, ImageViewParams viewParams) {
+ int type = carIcon.getType();
+ if (type == CarIcon.TYPE_APP_ICON) {
+ return templateContext.getCarAppPackageInfo().getRoundAppIcon();
+ }
+
+ CarIconConstraints constraints = viewParams.getConstraints();
+ try {
+ constraints.validateOrThrow(carIcon);
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ L.e(LogTags.TEMPLATE, e, "Failed to load drawable from an invalid icon: %s", carIcon);
+ return null;
+ }
+
+ // Either a custom icon, or a standard icon other than the app icon: get its backing icon.
+ IconCompat iconCompat = getBackingIconCompat(templateContext, carIcon);
+ if (iconCompat == null) {
+ return null;
+ }
+
+ // If tint is specified in the icon, overwrite the backing icon's tint.
+ @ColorInt int tintInt = getTintForIcon(templateContext, carIcon.getTint(), viewParams);
+
+ // Load the resource drawables from the app using the configuration context so that we get
+ // them
+ // with the right target DPI and theme attributes are resolved correctly.
+ if (iconCompat.getType() == TYPE_RESOURCE) {
+ String iconPackageName = iconCompat.getResPackage();
+ if (iconPackageName == null) {
+ // If an app sends an IconCompat created with an androidx.core version before 1.4,
+ // the
+ // package name will be null.
+ L.w(
+ LogTags.TEMPLATE,
+ "Failed to load drawable from an icon with an unknown package name: %s",
+ carIcon);
+ return null;
+ }
+
+ String packageName =
+ templateContext.getCarAppPackageInfo().getComponentName().getPackageName();
+
+ // Remote resource from the app?
+ if (iconPackageName.equals(packageName)) {
+ return loadAppResourceDrawable(templateContext, iconCompat, tintInt);
+ }
+ }
+
+ if (tintInt != TRANSPARENT) {
+ iconCompat.setTint(tintInt);
+ iconCompat.setTintMode(Mode.SRC_IN);
+ }
+
+ return iconCompat.loadDrawable(templateContext);
+ }
+
+ @Nullable
+ private static Drawable loadAppResourceDrawable(
+ TemplateContext templateContext, IconCompat iconCompat, @ColorInt int tintInt) {
+ String packageName =
+ templateContext.getCarAppPackageInfo().getComponentName().getPackageName();
+
+ int density = templateContext.getResources().getDisplayMetrics().densityDpi;
+ @SuppressLint("ResourceType")
+ @DrawableRes
+ int resId = iconCompat.getResId();
+
+ Context configurationContext = templateContext.getAppConfigurationContext();
+ if (configurationContext == null) {
+ L.e(
+ LogTags.TEMPLATE,
+ "Failed to load drawable for %d, configuration unavailable",
+ resId);
+ return null;
+ }
+
+ L.d(
+ LogTags.TEMPLATE,
+ "Loading resource drawable with id %d for density %d from package %s",
+ resId,
+ density,
+ packageName);
+
+ // Load the drawable passing the density explicitly.
+ // The IconCompat#loadDrawable path /should/ be able to do this, but it does not.
+ // See b/159103561 for details. A side effect of us branching off this code path is that
+ // the tint set in the IconCompat instance is not honored.
+ Drawable drawable =
+ configurationContext
+ .getResources()
+ .getDrawableForDensity(resId, density, configurationContext.getTheme());
+ if (drawable == null) {
+ L.e(LogTags.TEMPLATE, "Failed to load drawable for %d", resId);
+ return null;
+ }
+
+ if (tintInt != TRANSPARENT) {
+ drawable.mutate();
+ DrawableCompat.setTintList(drawable, ColorStateList.valueOf(tintInt));
+ DrawableCompat.setTintMode(drawable, Mode.SRC_IN);
+ }
+
+ return drawable;
+ }
+
+ @Nullable
+ private static IconCompat getBackingIconCompat(
+ TemplateContext templateContext, CarIcon carIcon) {
+ IconCompat iconCompat;
+ int type = carIcon.getType();
+ if (type == CarIcon.TYPE_CUSTOM) {
+ iconCompat = carIcon.getIcon();
+ if (iconCompat == null) {
+ L.e(LogTags.TEMPLATE, "Custom icon without backing icon: %s", carIcon);
+ return null;
+ }
+ } else { // a standard icon
+ @DrawableRes
+ int resId = drawableIdFromCarIconType(type, templateContext.getHostResourceIds());
+ if (resId == 0) {
+ L.e(LogTags.TEMPLATE, "Failed to find resource id for standard icon: %s", carIcon);
+ return null;
+ }
+
+ iconCompat = IconCompat.createWithResource(templateContext, resId);
+ if (iconCompat == null) {
+ L.e(LogTags.TEMPLATE, "Failed to load standard icon: %s", carIcon);
+ return null;
+ }
+ }
+
+ return iconCompat;
+ }
+
+ private ImageUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageViewParams.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageViewParams.java
new file mode 100644
index 0000000..f6cbc41
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageViewParams.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.common;
+
+import static android.graphics.Color.TRANSPARENT;
+
+import android.graphics.drawable.Drawable;
+import androidx.annotation.ColorInt;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarIcon;
+import com.android.car.libraries.apphost.distraction.constraints.CarIconConstraints;
+
+/** Encapsulates parameters that configure the way image view instances are rendered. */
+public final class ImageViewParams {
+ /** Callback for events related to image loading. */
+ public interface ImageLoadCallback {
+ /** Notifies that the load of the image failed. */
+ void onLoadFailed(@Nullable Throwable e);
+
+ /** Notifies that the images was successfully loaded. */
+ void onImageReady();
+ }
+
+ public static final ImageViewParams DEFAULT = ImageViewParams.builder().build();
+
+ @ColorInt private final int mDefaultTint;
+ private final boolean mForceTinting;
+ private final boolean mIgnoreAppTint;
+ private final boolean mIsDark;
+ private final CarIconConstraints mConstraints;
+ @Nullable private final Drawable mPlaceholderDrawable;
+ @Nullable private final ImageLoadCallback mImageLoadCallback;
+ @ColorInt private final int mBackgroundColor;
+
+ /**
+ * Returns the default tint color to apply to the image if one is not specified explicitly.
+ *
+ * @see Builder#setDefaultTint(int)
+ */
+ @ColorInt
+ public int getDefaultTint() {
+ return mDefaultTint;
+ }
+
+ /**
+ * Returns whether the default tint will be used when a {@link CarIcon} does not specify a tint.
+ *
+ * @see Builder#setForceTinting(boolean)
+ */
+ public boolean getForceTinting() {
+ return mForceTinting;
+ }
+
+ /** Returns whether the app-provided tint should be ignored. */
+ public boolean ignoreAppTint() {
+ return mIgnoreAppTint;
+ }
+
+ /**
+ * Returns whether to use the dark-variant of the tint color if one is provided.
+ *
+ * @see Builder#setIsDark(boolean)
+ */
+ public boolean getIsDark() {
+ return mIsDark;
+ }
+
+ /**
+ * Returns the {@link CarIconConstraints} to enforce when loading the image.
+ *
+ * @see Builder#setCarIconConstraints(CarIconConstraints)
+ */
+ public CarIconConstraints getConstraints() {
+ return mConstraints;
+ }
+
+ /**
+ * Returns the placeholder drawable to show while the image is loading or {@code null} to not show
+ * a placeholder image.
+ *
+ * @see Builder#setPlaceholderDrawable(Drawable)
+ */
+ @Nullable
+ public Drawable getPlaceholderDrawable() {
+ return mPlaceholderDrawable;
+ }
+
+ /**
+ * Returns the callback called when the image loading succeeds or fails or {@code null} if one is
+ * not set.
+ *
+ * @see Builder#setImageLoadCallback(ImageLoadCallback)
+ */
+ @Nullable
+ public ImageLoadCallback getImageLoadCallback() {
+ return mImageLoadCallback;
+ }
+
+ /**
+ * Sets the background color against which the text will be displayed.
+ *
+ * <p>This color is used only for the color contrast check, and will not be applied on the text
+ * background.
+ *
+ * <p>By default, the background color is assumed to be transparent.
+ */
+ @ColorInt
+ public int getBackgroundColor() {
+ return mBackgroundColor;
+ }
+
+ /** Returns a builder of {@link ImageViewParams}. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ private ImageViewParams(
+ @ColorInt int defaultTint,
+ boolean forceTinting,
+ boolean isDark,
+ CarIconConstraints constraints,
+ @Nullable Drawable placeholderDrawable,
+ @Nullable ImageLoadCallback imageLoadCallback,
+ boolean ignoreAppTint,
+ @ColorInt int backgroundColor) {
+ mDefaultTint = defaultTint;
+ mForceTinting = forceTinting;
+ mIsDark = isDark;
+ mConstraints = constraints;
+ mPlaceholderDrawable = placeholderDrawable;
+ mImageLoadCallback = imageLoadCallback;
+ mIgnoreAppTint = ignoreAppTint;
+ mBackgroundColor = backgroundColor;
+ }
+
+ /** A builder of {@link ImageViewParams} instances. */
+ public static class Builder {
+ @ColorInt private int mDefaultTint = TRANSPARENT;
+ private boolean mForceTinting;
+ private boolean mIgnoreAppTint;
+ private boolean mIsDark;
+ private CarIconConstraints mConstraints = CarIconConstraints.DEFAULT;
+ @Nullable private Drawable mPlaceholderDrawable;
+ @Nullable private ImageLoadCallback mImageLoadCallback;
+ @ColorInt private int mBackgroundColor = TRANSPARENT;
+
+ /**
+ * Sets the tint to use by default.
+ *
+ * <p>If not set, the initial value is {@code TRANSPARENT}.
+ *
+ * <p>The default tint is used if a {@link CarIcon}'s tint is {@link
+ * androidx.car.app.model.CarColor#DEFAULT}, or the icon does not specify a tint and {@code
+ * #setForceTinting(true)} is called.
+ */
+ public Builder setDefaultTint(@ColorInt int defaultTint) {
+ mDefaultTint = defaultTint;
+ return this;
+ }
+
+ /**
+ * Determines if the default tint will be used when a {@link CarIcon} does not specify a tint.
+ *
+ * <p>The default value is {@code false}.
+ *
+ * @see {@link #setDefaultTint(int)} for details on when the default tint is used
+ */
+ public Builder setForceTinting(boolean forceTinting) {
+ mForceTinting = forceTinting;
+ return this;
+ }
+
+ /** Determines if the app-provided icon tint should be ignored. */
+ public Builder setIgnoreAppTint(boolean ignoreAppTint) {
+ mIgnoreAppTint = ignoreAppTint;
+ return this;
+ }
+
+ /**
+ * Sets whether to use the dark-variant of the tint color if one is provided.
+ *
+ * <p>The default value is {@code false}.
+ */
+ public Builder setIsDark(boolean isDark) {
+ mIsDark = isDark;
+ return this;
+ }
+
+ /**
+ * Sets the {@link CarIconConstraints} to enforce when loading the image.
+ *
+ * <p>The default value is {@link CarIconConstraints#DEFAULT}.
+ */
+ public Builder setCarIconConstraints(CarIconConstraints constraints) {
+ mConstraints = constraints;
+ return this;
+ }
+
+ /**
+ * Sets the placeholder drawable to show while the image is loading.
+ *
+ * <p>The placeholder does not show for synchronously loaded images.
+ */
+ public Builder setPlaceholderDrawable(@Nullable Drawable placeholderDrawable) {
+ mPlaceholderDrawable = placeholderDrawable;
+ return this;
+ }
+
+ /**
+ * Sets a callback called when the image loading succeeds or fails.
+ *
+ * <p>The callback is ignored for synchronously loaded images.
+ */
+ public Builder setImageLoadCallback(@Nullable ImageLoadCallback imageLoadCallback) {
+ mImageLoadCallback = imageLoadCallback;
+ return this;
+ }
+
+ /**
+ * Sets the background color against which the text will be displayed.
+ *
+ * <p>This color is used only for the color contrast check, and will not be applied on the text
+ * background.
+ *
+ * <p>By default, the background color is assumed to be transparent.
+ */
+ public Builder setBackgroundColor(@ColorInt int backgroundColor) {
+ mBackgroundColor = backgroundColor;
+ return this;
+ }
+
+ /** Constructs a {@link ImageViewParams} instance defined by this builder. */
+ public ImageViewParams build() {
+ return new ImageViewParams(
+ mDefaultTint,
+ mForceTinting,
+ mIsDark,
+ mConstraints,
+ mPlaceholderDrawable,
+ mImageLoadCallback,
+ mIgnoreAppTint,
+ mBackgroundColor);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/widget/map/AbstractMapViewContainer.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/widget/map/AbstractMapViewContainer.java
new file mode 100644
index 0000000..9220a2e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/widget/map/AbstractMapViewContainer.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.view.widget.map;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import com.android.car.libraries.apphost.common.MapViewContainer;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** A layout that wraps a single map view */
+public abstract class AbstractMapViewContainer extends FrameLayout
+ implements LifecycleOwner, DefaultLifecycleObserver, MapViewContainer {
+
+ /** Returns an {@link AbstractMapViewContainer} instance. */
+ public AbstractMapViewContainer(
+ Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ /** Returns an {@link AbstractMapViewContainer} instance. */
+ public AbstractMapViewContainer(Context context) {
+ this(context, null, 0, 0);
+ }
+
+ /**
+ * Sets the {@link TemplateContext} to provide hosts and presenters.
+ *
+ * @param templateContext TemplateContext
+ */
+ public abstract void setTemplateContext(TemplateContext templateContext);
+}