diff options
Diffstat (limited to 'Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view')
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); +} |