summaryrefslogtreecommitdiff
path: root/Host/app/apphost/src/main/java/com
diff options
context:
space:
mode:
Diffstat (limited to 'Host/app/apphost/src/main/java/com')
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/AbstractHost.java103
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/CarHost.java401
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/Host.java46
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/HostFactory.java30
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/ManagerDispatcher.java47
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/NavigationIntentConverter.java221
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ANRHandler.java51
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ApiIncompatibilityType.java22
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppBindingStateProvider.java32
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppDispatcher.java230
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppHostService.java20
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppIconLoader.java35
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppServiceCall.java30
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/BackPressedHandler.java27
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppColors.java48
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppError.java181
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppManager.java32
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppPackageInfo.java42
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarColorUtils.java379
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarHostConfig.java203
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorContrastCheckState.java34
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorUtils.java165
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CommonUtils.java54
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/DebugOverlayHandler.java71
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorHandler.java25
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorMessageTemplateBuilder.java157
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/EventManager.java139
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/HostResourceIds.java215
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IncompatibleApiException.java32
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IntentUtils.java55
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/InvalidatedCarHostException.java25
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/LocationMediator.java75
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapGestureManager.java48
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapOnGestureListener.java293
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapViewContainer.java48
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/NamedAppServiceCall.java56
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OnDoneCallbackStub.java125
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OneWayIPC.java34
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/RoutingInfoState.java27
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ScaleGestureDetector.java555
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StatusBarManager.java46
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StringUtils.java72
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceCallbackHandler.java44
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceInfoProvider.java53
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SystemClockWrapper.java35
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/TemplateContext.java332
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ThreadUtils.java70
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ToastController.java31
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/BackFlowViolationException.java23
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/FlowViolationException.java23
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/OverLimitFlowViolationException.java23
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/TemplateValidator.java513
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/CheckerUtils.java130
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/GridTemplateChecker.java57
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/ListTemplateChecker.java88
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/MessageTemplateChecker.java45
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/NavigationTemplateChecker.java34
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PaneTemplateChecker.java50
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListMapTemplateChecker.java68
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListNavigationTemplateChecker.java65
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/RoutePreviewNavigationTemplateChecker.java65
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/SignInTemplateChecker.java61
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/TemplateChecker.java37
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ActionsConstraints.java161
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarColorConstraints.java78
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarIconConstraints.java86
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ConstraintsProvider.java50
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowConstraints.java176
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowListConstraints.java170
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditable.java31
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditableListener.java33
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputConfig.java25
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputManager.java52
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/ANRHandlerImpl.java130
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AndroidManifest.xml5
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AppDispatcherImpl.java385
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/BlockingOneWayIPC.java143
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBinding.java625
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBindingCallback.java28
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppPackageInfoImpl.java113
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/LocationMediatorImpl.java113
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/NullUtils.java98
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/PolyNull.java56
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApi.java59
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApiErrorType.java35
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/ContentLimitQuery.java62
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/L.java532
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/LogTags.java51
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/StatusReporter.java37
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryEvent.java165
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryHandler.java76
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationHost.java248
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationManagerDispatcher.java47
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationStateCallback.java33
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppHost.java635
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppManagerDispatcher.java75
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/ConstraintHost.java71
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/UIController.java41
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionStripWrapper.java95
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionWrapper.java72
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapper.java644
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapperTemplate.java183
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowWrapper.java271
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/SelectionGroup.java104
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractSurfaceTemplatePresenter.java166
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplatePresenter.java382
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/AbstractTemplateView.java466
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/PanZoomManager.java219
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceProvider.java103
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/SurfaceViewContainer.java146
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverter.java38
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateConverterRegistry.java68
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenter.java145
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterFactory.java36
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplatePresenterRegistry.java75
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/TemplateTransitionManager.java27
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ActionButtonListParams.java144
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextParams.java237
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/CarTextUtils.java484
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DateTimeUtils.java128
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/DistanceUtils.java99
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageUtils.java648
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/common/ImageViewParams.java258
-rw-r--r--Host/app/apphost/src/main/java/com/android/car/libraries/apphost/view/widget/map/AbstractMapViewContainer.java48
124 files changed, 16389 insertions, 0 deletions
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/AbstractHost.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/AbstractHost.java
new file mode 100644
index 0000000..752227f
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/AbstractHost.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;
+
+import static com.android.car.libraries.apphost.common.EventManager.EventType.APP_DISCONNECTED;
+import static com.android.car.libraries.apphost.common.EventManager.EventType.APP_UNBOUND;
+
+import android.content.Intent;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.L;
+import com.google.common.base.Preconditions;
+import java.io.PrintWriter;
+
+/**
+ * Abstract base class for {@link Host}s which implements some of the common host service
+ * functionality.
+ */
+public abstract class AbstractHost implements Host {
+ protected TemplateContext mTemplateContext;
+ private boolean mIsValid = true;
+ private final String mName;
+
+ @SuppressWarnings("nullness")
+ protected AbstractHost(TemplateContext templateContext, String name) {
+ mTemplateContext = templateContext;
+ mName = name;
+ addEventSubscriptions();
+ }
+
+ @Override
+ public void setTemplateContext(TemplateContext templateContext) {
+ removeEventSubscriptions();
+ mTemplateContext = templateContext;
+ addEventSubscriptions();
+ }
+
+ @Override
+ public void invalidateHost() {
+ mIsValid = false;
+ }
+
+ @Override
+ public void onCarAppBound() {}
+
+ @Override
+ public void onNewIntentDispatched() {}
+
+ @Override
+ public void onBindToApp(Intent intent) {}
+
+ @Override
+ public void reportStatus(PrintWriter pw, Pii piiHandling) {}
+
+ /** Called when the app is disconnected. */
+ public void onDisconnectedEvent() {}
+
+ /** Called when the app is unbound. */
+ public void onUnboundEvent() {}
+
+ /** Asserts that the service is valid. */
+ protected void assertIsValid() {
+ Preconditions.checkState(mIsValid, "Accessed a host service after it became invalidated");
+ }
+
+ /** Runs the {@code runnable} iff the host is valid. */
+ protected void runIfValid(String methodName, Runnable runnable) {
+ if (isValid()) {
+ runnable.run();
+ } else {
+ L.w(mName, "Accessed %s after host became invalidated", methodName);
+ }
+ }
+
+ /** Returns whether the host is valid. */
+ protected boolean isValid() {
+ return mIsValid;
+ }
+
+ private void addEventSubscriptions() {
+ mTemplateContext
+ .getEventManager()
+ .subscribeEvent(this, APP_DISCONNECTED, this::onDisconnectedEvent);
+ mTemplateContext.getEventManager().subscribeEvent(this, APP_UNBOUND, this::onUnboundEvent);
+ }
+
+ private void removeEventSubscriptions() {
+ mTemplateContext.getEventManager().unsubscribeEvent(this, APP_DISCONNECTED);
+ mTemplateContext.getEventManager().unsubscribeEvent(this, APP_UNBOUND);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/CarHost.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/CarHost.java
new file mode 100644
index 0000000..aa557b0
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/CarHost.java
@@ -0,0 +1,401 @@
+/*
+ * 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;
+
+import android.content.Intent;
+import android.os.IBinder;
+import android.util.Log;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.CarContext;
+import androidx.car.app.ICarApp;
+import androidx.car.app.ICarHost;
+import androidx.car.app.versioning.CarAppApiLevels;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.Lifecycle.Event;
+import androidx.lifecycle.Lifecycle.State;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LifecycleRegistry;
+import com.android.car.libraries.apphost.common.ANRHandler.ANRToken;
+import com.android.car.libraries.apphost.common.EventManager.EventType;
+import com.android.car.libraries.apphost.common.IntentUtils;
+import com.android.car.libraries.apphost.common.NamedAppServiceCall;
+import com.android.car.libraries.apphost.common.OnDoneCallbackStub;
+import com.android.car.libraries.apphost.common.StringUtils;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.internal.CarAppBinding;
+import com.android.car.libraries.apphost.internal.CarAppBindingCallback;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.logging.StatusReporter;
+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 com.google.common.base.Preconditions;
+import java.io.PrintWriter;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Host responsible for binding to and maintaining the lifecycle of a single app. */
+public class CarHost implements LifecycleOwner, StatusReporter {
+ // Suppress under-initialization checker warning for passing this to the LifecycleRegistry's
+ // ctor.
+ @SuppressWarnings("nullness")
+ private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this);
+
+ private final ICarHost.Stub mCarHostStub = new CarHostStubImpl();
+ private final CarAppBinding mCarAppBinding;
+ private final TelemetryHandler mTelemetryHandler;
+
+ // Key is a @CarAppService.
+ private final HashMap<String, Host> mHostServices = new HashMap<>();
+
+ private TemplateContext mTemplateContext;
+ private long mLastStartTimeMillis = -1;
+ private boolean mIsValid = true;
+ private boolean mIsAppBound = false;
+
+ /** Creates a {@link CarHost}. */
+ public static CarHost create(TemplateContext templateContext) {
+ return new CarHost(templateContext);
+ }
+
+ /**
+ * Binds to the app managed by this {@link CarHost} instance.
+ *
+ * @param intent the intent used to start the app.
+ */
+ public void bindToApp(Intent intent) {
+ assertIsValid();
+
+ for (Host host : mHostServices.values()) {
+ host.onBindToApp(intent);
+ }
+
+ // Remove the custom extras we put in the intent, if any.
+ IntentUtils.removeInternalIntentExtras(
+ intent, mTemplateContext.getCarHostConfig().getHostIntentExtrasToRemove());
+
+ mCarAppBinding.bind(intent);
+ mTelemetryHandler.logCarAppTelemetry(
+ TelemetryEvent.newBuilder(UiAction.APP_START, mCarAppBinding.getAppName()));
+ }
+
+ /** Unbinds from the app previously bound to with {@link #bindToApp}. */
+ public void unbindFromApp() {
+ mCarAppBinding.unbind();
+ }
+
+ /**
+ * Registers a {@link Host} with this host and returns the {@link CarHost}. This call is
+ * idempotent for the same {@code type}.
+ *
+ * @param type one of the CarServiceType as defined in {@link CarContext}
+ * @param factory factory for creating the {@link Host} corresponding to the service type
+ */
+ public Host registerHostService(String type, HostFactory factory) {
+ assertIsValid();
+ Host host = mHostServices.get(type);
+ if (host == null) {
+ host = factory.createHost(mCarAppBinding);
+ mHostServices.put(type, host);
+ }
+ return host;
+ }
+
+ /** Updates the {@link TemplateContext} when the template has destroyed an recreated. */
+ public void setTemplateContext(TemplateContext templateContext) {
+ // Since we are updating the TemplateContext, unsubscribe the event listener from the
+ // previous one.
+ mTemplateContext.getEventManager().unsubscribeEvent(this, EventType.CONFIGURATION_CHANGED);
+
+ mTemplateContext = templateContext;
+
+ mTemplateContext.getAppBindingStateProvider().updateAppBindingState(mIsAppBound);
+ mTemplateContext
+ .getEventManager()
+ .subscribeEvent(this, EventType.CONFIGURATION_CHANGED, this::onConfigurationChanged);
+ mCarAppBinding.setTemplateContext(templateContext);
+
+ for (Host host : mHostServices.values()) {
+ host.setTemplateContext(templateContext);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return mCarAppBinding.toString();
+ }
+
+ /**
+ * Returns the {@link Host} that is registered for the given {@code type}.
+ *
+ * @param type one of the CarServiceType as defined in {@link CarContext}
+ * @throws IllegalStateException if there are no services registered for the given {@code type}
+ */
+ public Host getHostOrThrow(String type) {
+ assertIsValid();
+ Host host = mHostServices.get(type);
+ if (host == null) {
+ throw new IllegalStateException("No host service registered for: " + type);
+ }
+ return host;
+ }
+
+ /** Dispatches the given lifecycle event to the app managed by this {host}. */
+ public void dispatchAppLifecycleEvent(Event event) {
+ Log.d(LogTags.APP_HOST, "AppLifecycleEvent: " + event);
+ assertIsValid();
+ mLifecycleRegistry.handleLifecycleEvent(event);
+ }
+
+ /** Invalidates the {@link CarHost} so that any subsequent call on any of the APIs will fail. */
+ public void invalidate() {
+ mIsValid = false;
+ for (Host host : mHostServices.values()) {
+ host.invalidateHost();
+ }
+
+ mLifecycleRegistry.handleLifecycleEvent(Event.ON_DESTROY);
+ }
+
+ /** Returns the {@link CarAppBinding} instance used to bind to the app. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ public CarAppBinding getCarAppBinding() {
+ return mCarAppBinding;
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ public TemplateContext getTemplateContext() {
+ return mTemplateContext;
+ }
+
+ /** Runs the logic necessary after the {@link CarHost} has successfully bound to the app. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ public void onAppBound() {
+ // Don't assert whether the object is valid here as this is an asynchronous API and could be
+ // called after being invalidated we don't want to cause a crash after the previous
+ // shutdown.
+ if (!mIsValid) {
+ return;
+ }
+
+ mIsAppBound = true;
+ mTemplateContext.getAppBindingStateProvider().updateAppBindingState(mIsAppBound);
+ for (Host host : mHostServices.values()) {
+ host.onCarAppBound();
+ }
+
+ // Binding is asynchronous, so when it completes, the lifecycle events may not have
+ // propagated. When lifecycle events happen before binding is complete, the lifecycle
+ // methods are dropped on the floor. Due to this, we will send lifecycle methods that
+ // may have happened since the bind began.
+ State currentState = mLifecycleRegistry.getCurrentState();
+ if (currentState.isAtLeast(State.STARTED)) {
+ mCarAppBinding.dispatchAppLifecycleEvent(Event.ON_START);
+ if (currentState.isAtLeast(State.RESUMED)) {
+ mCarAppBinding.dispatchAppLifecycleEvent(Event.ON_RESUME);
+ }
+ }
+ }
+
+ @Override
+ public void reportStatus(PrintWriter pw, Pii piiHandling) {
+ pw.printf("- state: %s\n", mLifecycleRegistry.getCurrentState());
+ pw.printf("- is valid: %b\n", mIsValid);
+
+ if (mLastStartTimeMillis >= 0) {
+ long durationMillis =
+ mTemplateContext.getSystemClockWrapper().elapsedRealtime() - mLastStartTimeMillis;
+ pw.printf("- duration: %s\n", StringUtils.formatDuration(durationMillis));
+ }
+
+ mCarAppBinding.reportStatus(pw, piiHandling);
+ mTemplateContext.reportStatus(pw);
+
+ for (Map.Entry<String, Host> entry : mHostServices.entrySet()) {
+ pw.printf("\nHost service: %s\n", entry.getKey());
+ entry.getValue().reportStatus(pw, piiHandling);
+ }
+ }
+
+ @Override
+ public Lifecycle getLifecycle() {
+ // Don't assert whether the object is valid here, since callers may use the lifecycle to
+ // know.
+ return mLifecycleRegistry;
+ }
+
+ /**
+ * Returns the stub for the {@link ICarHost} binder that apps use to communicate with this host.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ public ICarHost.Stub getCarHostStub() {
+ return mCarHostStub;
+ }
+
+ void onNewIntentDispatched() {
+ // Don't assert whether the object is valid here as this is an asynchronous API and could be
+ // called after being invalidated we don't want to cause a crash after the previous
+ // shutdown.
+ if (!mIsValid) {
+ return;
+ }
+
+ for (Host host : mHostServices.values()) {
+ host.onNewIntentDispatched();
+ }
+ }
+
+ private void onConfigurationChanged() {
+ if (mCarAppBinding.isBound()) {
+ mCarAppBinding.dispatch(
+ CarContext.CAR_SERVICE,
+ NamedAppServiceCall.create(
+ CarAppApi.ON_CONFIGURATION_CHANGED,
+ (ICarApp carApp, ANRToken anrToken) ->
+ carApp.onConfigurationChanged(
+ mTemplateContext.getResources().getConfiguration(),
+ new OnDoneCallbackStub(mTemplateContext, anrToken))));
+ }
+ }
+
+ @SuppressWarnings("nullness")
+ private CarHost(TemplateContext templateContext) {
+ mTemplateContext = templateContext;
+ mCarAppBinding =
+ CarAppBinding.create(
+ templateContext,
+ mCarHostStub,
+ new CarAppBindingCallback() {
+ @Override
+ public void onCarAppBound() {
+ onAppBound();
+ }
+
+ @Override
+ public void onNewIntentDispatched() {
+ CarHost.this.onNewIntentDispatched();
+ }
+
+ @Override
+ public void onCarAppUnbound() {
+ mIsAppBound = false;
+ templateContext.getAppBindingStateProvider().updateAppBindingState(mIsAppBound);
+ templateContext.getEventManager().dispatchEvent(EventType.APP_UNBOUND);
+ }
+ });
+
+ templateContext
+ .getEventManager()
+ .subscribeEvent(this, EventType.CONFIGURATION_CHANGED, this::onConfigurationChanged);
+
+ mTelemetryHandler = templateContext.getTelemetryHandler();
+
+ mLifecycleRegistry.handleLifecycleEvent(Event.ON_CREATE);
+
+ mLifecycleRegistry.addObserver(
+ new DefaultLifecycleObserver() {
+ @Override
+ public void onStart(LifecycleOwner lifecycleOwner) {
+ mLastStartTimeMillis = templateContext.getSystemClockWrapper().elapsedRealtime();
+ dispatch(Event.ON_START);
+ }
+
+ @Override
+ public void onResume(LifecycleOwner lifecycleOwner) {
+ dispatch(Event.ON_RESUME);
+ }
+
+ @Override
+ public void onPause(LifecycleOwner lifecycleOwner) {
+ dispatch(Event.ON_PAUSE);
+ }
+
+ @Override
+ public void onStop(LifecycleOwner lifecycleOwner) {
+ dispatch(Event.ON_STOP);
+ long durationMillis =
+ templateContext.getSystemClockWrapper().elapsedRealtime() - mLastStartTimeMillis;
+ if (mLastStartTimeMillis < 0 || durationMillis < 0) {
+ L.w(
+ LogTags.APP_HOST,
+ "Negative duration %d or negative last start time %d",
+ durationMillis,
+ mLastStartTimeMillis);
+ return;
+ }
+ mTelemetryHandler.logCarAppTelemetry(
+ TelemetryEvent.newBuilder(UiAction.APP_RUNTIME, mCarAppBinding.getAppName())
+ .setDurationMs(durationMillis));
+ mLastStartTimeMillis = -1;
+ }
+
+ private void dispatch(Event event) {
+ if (mCarAppBinding.isBound()) {
+ mCarAppBinding.dispatchAppLifecycleEvent(event);
+ }
+ }
+ });
+ }
+
+ private void assertIsValid() {
+ Preconditions.checkState(mIsValid, "Accessed the car host after it became invalidated");
+ }
+
+ private final class CarHostStubImpl extends ICarHost.Stub {
+ @Override
+ public void startCarApp(Intent intent) {
+ mTemplateContext.getCarAppManager().startCarApp(intent);
+ }
+
+ @Override
+ public void finish() {
+ mTemplateContext.getCarAppManager().finishCarApp();
+ }
+
+ @Override
+ public IBinder getHost(String type) {
+ assertIsValid();
+ Host service = mHostServices.get(type);
+ if (CarContext.NAVIGATION_SERVICE.equals(type)
+ && !mTemplateContext.getCarAppPackageInfo().isNavigationApp()) {
+ throw new IllegalArgumentException(
+ "Attempted to retrieve the navigation service, but the app is not a"
+ + " navigation app");
+ } else if (CarContext.CONSTRAINT_SERVICE.equals(type)
+ && mTemplateContext.getCarHostConfig().getNegotiatedApi() < CarAppApiLevels.LEVEL_2) {
+ throw new IllegalArgumentException(
+ "Attempted to retrieve the constraint service, but the host's API level is"
+ + " less than "
+ + CarAppApiLevels.LEVEL_2);
+ } else if (CarContext.HARDWARE_SERVICE.equals(type)
+ && mTemplateContext.getCarHostConfig().getNegotiatedApi() < CarAppApiLevels.LEVEL_3) {
+ throw new IllegalArgumentException(
+ "Attempted to retrieve the hardware service, but the host's API level is"
+ + " less than "
+ + CarAppApiLevels.LEVEL_3);
+ }
+
+ if (service != null) {
+ return service.getBinder();
+ }
+
+ throw new IllegalArgumentException("Unknown host service type:" + type);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/Host.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/Host.java
new file mode 100644
index 0000000..b76f074
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/Host.java
@@ -0,0 +1,46 @@
+/*
+ * 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;
+
+import android.content.Intent;
+import android.os.IBinder;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.StatusReporter;
+
+/**
+ * A host that manages app specific behaviors such as managing template APIs, navigation APIs, etc.
+ *
+ * <p>This should be registered with {@link CarHost}.
+ */
+public interface Host extends StatusReporter {
+ /** Invalidates the {@link Host} so that any subsequent call on any of the APIs will fail. */
+ void invalidateHost();
+
+ /** Informs the {@link Host} that an {@link Intent} has been received to bind to the app. */
+ void onBindToApp(Intent intent);
+
+ /** Indicates that the {@link CarHost} is now bound to the app. */
+ void onCarAppBound();
+
+ /** Indicates that a {@code onNewIntent} call has been dispatched to the app. */
+ void onNewIntentDispatched();
+
+ /** Returns the binder interface that the app can use to talk to this host. */
+ IBinder getBinder();
+
+ /** Sets the updated {@link TemplateContext} in this host instance. */
+ void setTemplateContext(TemplateContext templateContext);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/HostFactory.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/HostFactory.java
new file mode 100644
index 0000000..f0cb3bb
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/HostFactory.java
@@ -0,0 +1,30 @@
+/*
+ * 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;
+
+import com.android.car.libraries.apphost.internal.CarAppBinding;
+
+/** A factory of {@link Host} instances. */
+public interface HostFactory {
+ /**
+ * Creates a {@link Host} instance.
+ *
+ * @param appBinding the binding to use to dispatch calls to the client. This is upper bounded to
+ * {@link Object} and down-casted later to avoid making {@link CarAppBinding} public, while
+ * allowing round-tripping it outside of the package
+ */
+ Host createHost(Object appBinding);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/ManagerDispatcher.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/ManagerDispatcher.java
new file mode 100644
index 0000000..1219bad
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/ManagerDispatcher.java
@@ -0,0 +1,47 @@
+/*
+ * 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;
+
+import android.content.ComponentName;
+import android.os.IInterface;
+import androidx.annotation.AnyThread;
+import com.android.car.libraries.apphost.common.NamedAppServiceCall;
+import com.android.car.libraries.apphost.internal.CarAppBinding;
+
+/**
+ * A one-way dispatcher of calls to the client app.
+ *
+ * @param <ServiceT> The type of service to dispatch calls for.
+ */
+public abstract class ManagerDispatcher<ServiceT extends IInterface> {
+ private final String mManagerType;
+ private final CarAppBinding mAppBinding;
+
+ public ComponentName getAppName() {
+ return mAppBinding.getAppName();
+ }
+
+ protected ManagerDispatcher(String managerType, Object appBinding) {
+ mManagerType = managerType;
+ mAppBinding = (CarAppBinding) appBinding;
+ }
+
+ /** Dispatches the {@code call} to the appropriate app service. */
+ @AnyThread
+ protected void dispatch(NamedAppServiceCall<ServiceT> call) {
+ mAppBinding.dispatch(mManagerType, call);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/NavigationIntentConverter.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/NavigationIntentConverter.java
new file mode 100644
index 0000000..e4d0584
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/NavigationIntentConverter.java
@@ -0,0 +1,221 @@
+/*
+ * 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;
+
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.car.app.CarContext;
+import androidx.car.app.model.CarLocation;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import java.util.List;
+
+/**
+ * A helper class to convert template navigation {@link Intent}s to/from legacy format.
+ *
+ * <p>Legacy apps are navigation apps that are non template (gmm, waze and kakao).
+ *
+ * <p>Legacy apps currently support a "https://maps.google.com/maps" uri, which we are not going to
+ * force all nav apps to support.
+ *
+ * <p>There are other navigation uris that some legacy apps support, such as "google.navigation:" or
+ * "google.maps:", but not all of them do.
+ *
+ * <p>The format for the uri for new navigation apps is described at {@link CarContext#startCarApp}.
+ */
+public final class NavigationIntentConverter {
+ public static final String GEO_QUERY_PREFIX = "geo";
+
+ private static final String LEGACY_NAVIGATION_INTENT_DATA_PREFIX =
+ "https://maps.google.com/maps?nav=1&q=";
+
+ private static final String NAV_PREFIX = "google.navigation";
+ private static final String MAPS_PREFIX = "google.maps";
+
+ private static final String HTTP_MAPS_URL_PREFIX = "http://maps.google.com";
+ private static final String HTTPS_MAPS_URL_PREFIX = "https://maps.google.com";
+ private static final String HTTPS_ASSISTANT_MAPS_URL_PREFIX = "https://assistant-maps.google.com";
+
+ private static final String TEMPLATE_NAVIGATION_INTENT_DATA_LAT_LNG_PREFIX =
+ GEO_QUERY_PREFIX + ":";
+ private static final String TEMPLATE_NAVIGATION_INTENT_DATA_PREFIX =
+ TEMPLATE_NAVIGATION_INTENT_DATA_LAT_LNG_PREFIX + "0,0?q=";
+
+ private static final String SEARCH_QUERY_PARAMETER = "q";
+ private static final String SEARCH_QUERY_PARAMETER_SPLITTER = SEARCH_QUERY_PARAMETER + "=";
+ private static final String ADDRESS_QUERY_PARAMETER = "daddr";
+ private static final String ADDRESS_QUERY_PARAMETER_SPLITTER = ADDRESS_QUERY_PARAMETER + "=";
+
+ /**
+ * Converts the given {@code navIntent} to one that is supported by legacy apps.
+ *
+ * <p>This method <strong>will update</strong> the {@link Intent} provided.
+ *
+ * @see CarContext#startCarApp for format documentation
+ */
+ public static void toLegacyNavIntent(Intent navIntent) {
+ L.d(LogTags.APP_HOST, "Converting to legacy nav intent %s", navIntent);
+
+ navIntent.setAction(Intent.ACTION_VIEW);
+
+ Uri navUri = Preconditions.checkNotNull(navIntent.getData());
+
+ // Cleanup by removing spaces.
+ CarLocation location = getCarLocation(navUri);
+
+ if (location != null) {
+ navIntent.setData(
+ Uri.parse(
+ LEGACY_NAVIGATION_INTENT_DATA_PREFIX
+ + location.getLatitude()
+ + ","
+ + location.getLongitude()));
+ } else {
+ String query = getQueryString(navUri);
+ if (query == null) {
+ throw new IllegalArgumentException("Navigation intent is not properly formed");
+ }
+ navIntent.setData(
+ Uri.parse(LEGACY_NAVIGATION_INTENT_DATA_PREFIX + query.replaceAll("\\s", "+")));
+ }
+ L.d(LogTags.APP_HOST, "Converted to legacy nav intent %s", navIntent);
+ }
+
+ /** Verifies if the given {@link Intent} is for navigation with a legacy navigation app. */
+ public static boolean isLegacyNavIntent(Intent intent) {
+ Uri uri = intent.getData();
+
+ if (uri == null) {
+ return false;
+ }
+
+ String scheme = uri.getScheme();
+ String dataString = intent.getDataString();
+ return GEO_QUERY_PREFIX.equals(scheme)
+ || NAV_PREFIX.equals(scheme)
+ || MAPS_PREFIX.equals(scheme)
+ || Strings.nullToEmpty(dataString).startsWith(HTTP_MAPS_URL_PREFIX) // NOLINT
+ || Strings.nullToEmpty(dataString).startsWith(HTTPS_MAPS_URL_PREFIX) // NOLINT
+ || Strings.nullToEmpty(dataString).startsWith(HTTPS_ASSISTANT_MAPS_URL_PREFIX); // NOLINT
+ }
+
+ /**
+ * Converts the given {@code legacyIntent} to one that is supported by template navigation apps.
+ *
+ * <p>This method <strong>will update</strong> the {@link Intent} provided.
+ *
+ * @see CarContext#startCarApp for the template navigation {@link Intent} format
+ */
+ public static void fromLegacyNavIntent(Intent legacyIntent) {
+ L.d(LogTags.APP_HOST, "Converting from legacy nav intent %s", legacyIntent);
+ Preconditions.checkArgument(isLegacyNavIntent(legacyIntent));
+
+ legacyIntent.setAction(CarContext.ACTION_NAVIGATE);
+
+ Uri uri = Preconditions.checkNotNull(legacyIntent.getData());
+
+ CarLocation location = getCarLocation(uri);
+
+ if (location != null) {
+ legacyIntent.setData(
+ Uri.parse(
+ TEMPLATE_NAVIGATION_INTENT_DATA_LAT_LNG_PREFIX
+ + location.getLatitude()
+ + ","
+ + location.getLongitude()));
+ } else {
+ String query = getQueryString(uri);
+ if (query == null) {
+ throw new IllegalArgumentException("Navigation intent is not properly formed");
+ }
+ legacyIntent.setData(
+ Uri.parse(TEMPLATE_NAVIGATION_INTENT_DATA_PREFIX + query.replaceAll("\\s", "+")));
+ }
+ L.d(LogTags.APP_HOST, "Converted from legacy nav intent %s", legacyIntent);
+ }
+
+ /**
+ * Returns the latitude, longitude from the {@link Uri}, or {@code null} if none exists.
+ *
+ * <p>e.g. if Uri string is "geo:123.45,98.09", return value will be a {@link CarLocation} with
+ * 123.45 latitude and 98.09 longitude.
+ *
+ * <p>e.g. if Uri string is "https://maps.google.com/maps?q=123.45,98.09&nav=1", return value will
+ * be a {@link CarLocation} with 123.45 latitude and 98.09 longitude.
+ */
+ @Nullable
+ public static CarLocation getCarLocation(Uri uri) {
+ String possibleLatLng = getQueryString(uri);
+ if (possibleLatLng == null) {
+ // If not after a q=, uri is valid as geo:12.34,34.56
+ possibleLatLng = uri.getEncodedSchemeSpecificPart();
+ }
+
+ List<String> latLngParts = Splitter.on(',').splitToList(possibleLatLng);
+ if (latLngParts.size() == 2) {
+ try {
+ // Ensure both parts are doubles.
+ return CarLocation.create(
+ Double.parseDouble(latLngParts.get(0)), Double.parseDouble(latLngParts.get(1)));
+ } catch (NumberFormatException e) {
+ // Values are not Doubles.
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the actual query from the {@link Uri}, or {@code null} if none exists.
+ *
+ * <p>The query will be after "q=" or "daddr=".
+ *
+ * <p>e.g. if Uri string is "geo:0,0?q=124+Foo+St", return value will be "124+Foo+St".
+ *
+ * <p>e.g. if Uri string is "https://maps.google.com/maps?daddr=123+main+st&nav=1", return value
+ * will be "123+main+st".
+ */
+ @Nullable
+ public static String getQueryString(Uri uri) {
+ if (uri.isHierarchical()) {
+ List<String> query = uri.getQueryParameters(SEARCH_QUERY_PARAMETER);
+
+ if (query.isEmpty()) {
+ // No q= parameter, check if there is a daddr= parameter.
+ query = uri.getQueryParameters(ADDRESS_QUERY_PARAMETER);
+ }
+ return Iterables.getFirst(query, null);
+ }
+
+ String schemeSpecificPart = uri.getEncodedSchemeSpecificPart();
+ List<String> parts =
+ Splitter.on(SEARCH_QUERY_PARAMETER_SPLITTER).splitToList(schemeSpecificPart);
+
+ if (parts.size() < 2) {
+ // Did not find "q=".
+ parts = Splitter.on(ADDRESS_QUERY_PARAMETER_SPLITTER).splitToList(schemeSpecificPart);
+ }
+
+ // If we have a valid split on "q=" or "daddr=", split on "&" to only get the one parameter.
+ return parts.size() < 2 ? null : Splitter.on("&").splitToList(parts.get(1)).get(0);
+ }
+
+ private NavigationIntentConverter() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ANRHandler.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ANRHandler.java
new file mode 100644
index 0000000..6ffca5b
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ANRHandler.java
@@ -0,0 +1,51 @@
+/*
+ * 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.common;
+
+import com.android.car.libraries.apphost.logging.CarAppApi;
+
+/** Handles checking if an app does not respond in a timely manner. */
+public interface ANRHandler {
+ /** Time to wait for ANR check. */
+ int ANR_TIMEOUT_MS = 5000;
+
+ /**
+ * Performs the call and checks for application not responding.
+ *
+ * <p>The ANR check will happen in {@link #ANR_TIMEOUT_MS} milliseconds after calling {@link
+ * ANRCheckingCall#call}.
+ */
+ void callWithANRCheck(CarAppApi carAppApi, ANRCheckingCall call);
+
+ /** Token for dismissing the ANR check. */
+ interface ANRToken {
+ /** Requests dismissal of the ANR check. */
+ void dismiss();
+
+ /** Returns the {@link CarAppApi} that this token is for. */
+ CarAppApi getCarAppApi();
+ }
+
+ /** A call that checks for ANR and receives a token to use for dismissing the ANR check. */
+ interface ANRCheckingCall {
+ /**
+ * Performs the call.
+ *
+ * @param anrToken the token to use for dismissing the ANR check when the app calls back
+ */
+ void call(ANRToken anrToken);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ApiIncompatibilityType.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ApiIncompatibilityType.java
new file mode 100644
index 0000000..4a44b25
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ApiIncompatibilityType.java
@@ -0,0 +1,22 @@
+/*
+ * 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.common;
+
+/** Defines which type of incompatibility this exception is for. */
+public enum ApiIncompatibilityType {
+ APP_TOO_OLD,
+ HOST_TOO_OLD;
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppBindingStateProvider.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppBindingStateProvider.java
new file mode 100644
index 0000000..a3a4b8e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppBindingStateProvider.java
@@ -0,0 +1,32 @@
+/*
+ * 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.common;
+
+/** Container class for letting the rest of the host knows whether the app is bound. */
+public final class AppBindingStateProvider {
+
+ private boolean mIsAppBound = false;
+
+ /** Returns whether the app is bound. */
+ public boolean isAppBound() {
+ return mIsAppBound;
+ }
+
+ /** Updates the app binding state to the input value. */
+ public void updateAppBindingState(boolean isAppBound) {
+ mIsAppBound = isAppBound;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppDispatcher.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppDispatcher.java
new file mode 100644
index 0000000..25d9ee3
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppDispatcher.java
@@ -0,0 +1,230 @@
+/*
+ * 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.common;
+
+import android.graphics.Rect;
+import android.os.RemoteException;
+import androidx.car.app.ISurfaceCallback;
+import androidx.car.app.OnDoneCallback;
+import androidx.car.app.SurfaceContainer;
+import androidx.car.app.model.InputCallbackDelegate;
+import androidx.car.app.model.OnCheckedChangeDelegate;
+import androidx.car.app.model.OnClickDelegate;
+import androidx.car.app.model.OnContentRefreshDelegate;
+import androidx.car.app.model.OnItemVisibilityChangedDelegate;
+import androidx.car.app.model.OnSelectedDelegate;
+import androidx.car.app.model.SearchCallbackDelegate;
+import androidx.car.app.navigation.model.PanModeDelegate;
+import androidx.car.app.serialization.BundlerException;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+
+/**
+ * Class to set up safe remote callbacks to apps.
+ *
+ * <p>App interfaces to client are {@code oneway} so the calling thread does not block waiting for a
+ * response. (see go/aidl-best-practices for more information).
+ */
+public interface AppDispatcher {
+ /**
+ * Dispatches a {@link ISurfaceCallback#onSurfaceAvailable} to the provided listener with the
+ * provided container.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchSurfaceAvailable(
+ ISurfaceCallback surfaceListener, SurfaceContainer surfaceContainer);
+
+ /**
+ * Dispatches a {@link ISurfaceCallback#onSurfaceDestroyed} to the provided listener with the
+ * provided container.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchSurfaceDestroyed(
+ ISurfaceCallback surfaceListener, SurfaceContainer surfaceContainer);
+
+ /**
+ * Dispatches a {@link ISurfaceCallback#onVisibleAreaChanged} to the provided listener with the
+ * provided area.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchVisibleAreaChanged(ISurfaceCallback surfaceListener, Rect visibleArea);
+
+ /**
+ * Dispatches a {@link ISurfaceCallback#onStableAreaChanged} to the provided listener with the
+ * provided area.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchStableAreaChanged(ISurfaceCallback surfaceListener, Rect stableArea);
+
+ /**
+ * Dispatches a {@link ISurfaceCallback#onScroll} to the provided listener with the provided
+ * scroll distance.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchOnSurfaceScroll(ISurfaceCallback surfaceListener, float distanceX, float distanceY);
+
+ /**
+ * Dispatches a {@link ISurfaceCallback#onFling} to the provided listener with the provided fling
+ * velocity.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchOnSurfaceFling(ISurfaceCallback surfaceListener, float velocityX, float velocityY);
+
+ /**
+ * Dispatches a {@link ISurfaceCallback#onScale} to the provided listener with the provided focal
+ * point and scale factor.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchOnSurfaceScale(
+ ISurfaceCallback surfaceListener, float focusX, float focusY, float scaleFactor);
+
+ /**
+ * Dispatches a {@link SearchCallbackDelegate#sendSearchTextChanged} to the provided listener with
+ * the provided search text.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchSearchTextChanged(SearchCallbackDelegate searchCallbackDelegate, String searchText);
+
+ /**
+ * Dispatches a {@link SearchCallbackDelegate#sendSearchSubmitted} to the provided listener with
+ * the provided search text.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchSearchSubmitted(SearchCallbackDelegate searchCallbackDelegate, String searchText);
+
+ /**
+ * Dispatches an {@link InputCallbackDelegate#sendInputTextChanged} to the provided listener with
+ * the provided input text.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchInputTextChanged(InputCallbackDelegate inputCallbackDelegate, String inputText);
+
+ /**
+ * Dispatches an {@link InputCallbackDelegate#sendInputSubmitted} to the provided listener with
+ * the provided input text.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchInputSubmitted(InputCallbackDelegate inputCallbackDelegate, String inputText);
+
+ /**
+ * Dispatches a {@link OnItemVisibilityChangedDelegate#sendItemVisibilityChanged} to the provided
+ * listener.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchItemVisibilityChanged(
+ OnItemVisibilityChangedDelegate onItemVisibilityChangedDelegate,
+ int startIndexInclusive,
+ int endIndexExclusive);
+
+ /**
+ * Dispatches a {@link OnSelectedDelegate#sendSelected} to the provided listener.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchSelected(OnSelectedDelegate onSelectedDelegate, int index);
+
+ /**
+ * Dispatches a {@link OnCheckedChangeDelegate#sendCheckedChange} to the provided listener.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchCheckedChanged(OnCheckedChangeDelegate onCheckedChangeDelegate, boolean isChecked);
+
+ /**
+ * Dispatches a {@link PanModeDelegate#sendPanModeChanged(boolean, OnDoneCallback)} to the
+ * provided listener.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchPanModeChanged(PanModeDelegate panModeDelegate, boolean isChecked);
+
+ /**
+ * Dispatches a {@link OnClickDelegate#sendClick} to the provided listener.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchClick(OnClickDelegate onClickDelegate);
+
+ /**
+ * Dispatches a {@link OnContentRefreshDelegate#sendContentRefreshRequested} event.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi) for information on error handling
+ */
+ void dispatchContentRefreshRequest(OnContentRefreshDelegate onContentRefreshDelegate);
+
+ /**
+ * Performs the IPC.
+ *
+ * <p>The calls are oneway. Given this any exception thrown by the client will not reach us, they
+ * will be in their own process. (see go/aidl-best-practices for more information).
+ *
+ * <p>This method will handle app exceptions (described below) as well as {@link BundlerException}
+ * which would be thrown if the host fails to bundle an object before sending it over (should
+ * never happen).
+ *
+ * <h1>App Exceptions</h1>
+ *
+ * <p>Here are the possible exceptions thrown by the app, and when they may happen.
+ *
+ * <dl>
+ * <dt>{@link RemoteException}
+ * <dd>This exception is thrown when the binder is dead (i.e. the app crashed).
+ * <dt>{@link RuntimeException}
+ * <dd>The should not happen in regular scenario. The only cases where may happen are if the app
+ * is running in the same process as the host, or if the IPC was wrongly configured to not
+ * be {@code oneway}.
+ * </dl>
+ *
+ * <p>The following are the types of {@link RuntimeException} that the binder let's through. See
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/os/Parcel.java;l=2061-2094
+ *
+ * <ul>
+ * <li>{@link SecurityException}
+ * <li>{@link android.os.BadParcelableException}
+ * <li>{@link IllegalArgumentException}
+ * <li>{@link NullPointerException}
+ * <li>{@link IllegalStateException}
+ * <li>{@link android.os.NetworkOnMainThreadException}
+ * <li>{@link UnsupportedOperationException}
+ * <li>{@link android.os.ServiceSpecificException}
+ * <li>{@link RuntimeException} - for any other exceptions.
+ * </ul>
+ */
+ void dispatch(OneWayIPC ipc, CarAppApi carAppApi);
+
+ /**
+ * Performs the IPC allowing caller to define behavior for handling any exceptions.
+ *
+ * @see #dispatch(OneWayIPC, CarAppApi)
+ */
+ void dispatch(OneWayIPC ipc, ExceptionHandler exceptionHandler, CarAppApi carAppApi);
+
+ /** Will handle exceptions received while performing a {@link OneWayIPC}. */
+ interface ExceptionHandler {
+ void handle(CarAppError carAppError);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppHostService.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppHostService.java
new file mode 100644
index 0000000..83d69a0
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppHostService.java
@@ -0,0 +1,20 @@
+/*
+ * 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.common;
+
+/** Defines a service that can be retrieved from a {@link TemplateContext} */
+public interface AppHostService {}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppIconLoader.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppIconLoader.java
new file mode 100644
index 0000000..94ec4c8
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppIconLoader.java
@@ -0,0 +1,35 @@
+/*
+ * 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.common;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import androidx.annotation.NonNull;
+
+/** Interface that allows loading application icons */
+public interface AppIconLoader {
+
+ /**
+ * Returns a rounded app icon for the given {@link ComponentName}, or a default icon if the given
+ * {@link ComponentName} doesn't match an installed application.
+ *
+ * <p>Implementations must ensure method is thread-safe.
+ */
+ @NonNull
+ Drawable getRoundAppIcon(@NonNull Context context, @NonNull ComponentName componentName);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppServiceCall.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppServiceCall.java
new file mode 100644
index 0000000..4d95823
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/AppServiceCall.java
@@ -0,0 +1,30 @@
+/*
+ * 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.common;
+
+import android.os.RemoteException;
+import com.android.car.libraries.apphost.common.ANRHandler.ANRToken;
+
+/**
+ * Defines a call to make to an app service.
+ *
+ * @param <ServiceT> the service to receive the call
+ */
+public interface AppServiceCall<ServiceT> {
+ /** Dispatches the call. */
+ void dispatch(ServiceT appService, ANRToken anrToken) throws RemoteException;
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/BackPressedHandler.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/BackPressedHandler.java
new file mode 100644
index 0000000..3b6e46e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/BackPressedHandler.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.common;
+
+/** Interface for handling back button press. */
+public interface BackPressedHandler {
+
+ /**
+ * Forwards a back pressed event to the car app's {@link
+ * androidx.car.app.IAppManager#onBackPressed}.
+ */
+ void onBackPressed();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppColors.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppColors.java
new file mode 100644
index 0000000..c297120
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppColors.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.common;
+
+import android.content.Context;
+import android.content.res.Resources;
+import androidx.annotation.ColorInt;
+
+/** A container class for a car app's primary and secondary colors. */
+public class CarAppColors {
+ @ColorInt public final int primaryColor;
+ @ColorInt public final int primaryDarkColor;
+ @ColorInt public final int secondaryColor;
+ @ColorInt public final int secondaryDarkColor;
+
+ /** Constructs an instance of {@link CarAppColors}. */
+ public CarAppColors(
+ int primaryColor, int primaryDarkColor, int secondaryColor, int secondaryDarkColor) {
+ this.primaryColor = primaryColor;
+ this.primaryDarkColor = primaryDarkColor;
+ this.secondaryColor = secondaryColor;
+ this.secondaryDarkColor = secondaryDarkColor;
+ }
+
+ /** Returns a default {@link CarAppColors} to use, based on the host's default colors. */
+ public static CarAppColors getDefault(Context context, HostResourceIds hostResourceIds) {
+ Resources resources = context.getResources();
+ return new CarAppColors(
+ resources.getColor(hostResourceIds.getDefaultPrimaryColor()),
+ resources.getColor(hostResourceIds.getDefaultPrimaryDarkColor()),
+ resources.getColor(hostResourceIds.getDefaultSecondaryColor()),
+ resources.getColor(hostResourceIds.getDefaultSecondaryDarkColor()));
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppError.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppError.java
new file mode 100644
index 0000000..edb7355
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppError.java
@@ -0,0 +1,181 @@
+/*
+ * 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.common;
+
+import android.content.ComponentName;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** A class that encapsulates an error message that occurs for an app. */
+public class CarAppError {
+ /** Error type. Each type corresponds to an specific message to be displayed to the user */
+ public enum Type {
+ /** The client application is not responding in timely fashion */
+ ANR_TIMEOUT,
+
+ /** The user has requested to wait for the application to respond */
+ ANR_WAITING,
+
+ /** The client is using a version of the SDK that is not compatible with this host */
+ INCOMPATIBLE_CLIENT_VERSION,
+
+ /** The client does not have a required permission */
+ MISSING_PERMISSION,
+ }
+
+ private final ComponentName mAppName;
+ @Nullable private final Type mType;
+ @Nullable private final Throwable mCause;
+ @Nullable private final String mDebugMessage;
+ @Nullable private final Runnable mExtraAction;
+ private final boolean mLogVerbose;
+
+ /** Returns a {@link Builder} for the given {@code appName}. */
+ public static Builder builder(ComponentName appName) {
+ return new Builder(appName);
+ }
+
+ /** Returns the {@link ComponentName} representing an app. */
+ public ComponentName getAppName() {
+ return mAppName;
+ }
+
+ /**
+ * Returns the error type or {@code null} to show a generic error message.
+ *
+ * @see Builder#setType
+ */
+ @Nullable
+ public Type getType() {
+ return mType;
+ }
+
+ /**
+ * Returns the debug message for displaying in the DHU or any head unit on debug builds.
+ *
+ * @see Builder#setDebugMessage
+ */
+ @Nullable
+ public String getDebugMessage() {
+ return mDebugMessage;
+ }
+
+ /**
+ * Returns the debug message for displaying in the DHU or any head unit on debug builds.
+ *
+ * @see Builder#setCause
+ */
+ @Nullable
+ public Throwable getCause() {
+ return mCause;
+ }
+
+ /**
+ * Returns the {@code action} for the error screen shown to the user, on top of the exit which is
+ * default.
+ *
+ * @see Builder#setExtraAction
+ */
+ @Nullable
+ public Runnable getExtraAction() {
+ return mExtraAction;
+ }
+
+ /**
+ * Returns whether to log this {@link CarAppError} as a verbose log.
+ *
+ * <p>The default is to log as error, but can be overridden via {@link Builder#setLogVerbose}
+ */
+ public boolean logVerbose() {
+ return mLogVerbose;
+ }
+
+ @Override
+ public String toString() {
+ return "[app: "
+ + mAppName
+ + ", type: "
+ + mType
+ + ", cause: "
+ + (mCause != null
+ ? mCause.getClass().getCanonicalName() + ": " + mCause.getMessage()
+ : null)
+ + ", debug msg: "
+ + mDebugMessage
+ + "]";
+ }
+
+ private CarAppError(Builder builder) {
+ mAppName = builder.mAppName;
+ mType = builder.mType;
+ mCause = builder.mCause;
+ mDebugMessage = builder.mDebugMessage;
+ mExtraAction = builder.mExtraAction;
+ mLogVerbose = builder.mLogVerbose;
+ }
+
+ /** A builder for {@link CarAppError}. */
+ public static class Builder {
+ private final ComponentName mAppName;
+ @Nullable private Type mType;
+ @Nullable private Throwable mCause;
+ @Nullable private String mDebugMessage;
+ @Nullable private Runnable mExtraAction;
+ public boolean mLogVerbose;
+
+ private Builder(ComponentName appName) {
+ mAppName = appName;
+ }
+
+ /** Sets the error type, or {@code null} to show a generic error message. */
+ public Builder setType(Type type) {
+ mType = type;
+ return this;
+ }
+
+ /** Sets the exception for displaying in the DHU or any head unit on debug builds. */
+ public Builder setCause(Throwable cause) {
+ mCause = cause;
+ return this;
+ }
+
+ /** Sets the debug message for displaying in the DHU or any head unit on debug builds. */
+ public Builder setDebugMessage(String debugMessage) {
+ mDebugMessage = debugMessage;
+ return this;
+ }
+
+ /**
+ * Adds the {@code action} to the error screen shown to the user, on top of the exit which is
+ * default.
+ */
+ public Builder setExtraAction(Runnable extraAction) {
+ mExtraAction = extraAction;
+ return this;
+ }
+
+ /** Sets whether to log the {@link CarAppError} as verbose only. */
+ public Builder setLogVerbose(boolean logVerbose) {
+ mLogVerbose = logVerbose;
+ return this;
+ }
+
+ /** Constructs the {@link CarAppError} instance. */
+ public CarAppError build() {
+ return new CarAppError(this);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppManager.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppManager.java
new file mode 100644
index 0000000..f06342e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppManager.java
@@ -0,0 +1,32 @@
+/*
+ * 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.common;
+
+import android.content.Intent;
+
+/** Controls the ability to start a new car app, as well as finish the current car app. */
+public interface CarAppManager {
+ /**
+ * Starts a car app on the car screen.
+ *
+ * @see androidx.car.app.CarContext#startCarApp
+ */
+ void startCarApp(Intent intent);
+
+ /** Unbinds from the car app, and goes to the app launcher if the app is currently foreground. */
+ void finishCarApp();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppPackageInfo.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppPackageInfo.java
new file mode 100644
index 0000000..24b0772
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarAppPackageInfo.java
@@ -0,0 +1,42 @@
+/*
+ * 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.common;
+
+import android.content.ComponentName;
+import android.graphics.drawable.Drawable;
+import androidx.annotation.NonNull;
+
+/** Provides package information of a car app. */
+public interface CarAppPackageInfo {
+ /** Package and service name of the 3p car app. */
+ @NonNull
+ ComponentName getComponentName();
+
+ /**
+ * Returns the primary and secondary colors of the app as defined in the metadata entry for the
+ * app service, or default app theme if the metadata entry is not specified.
+ */
+ @NonNull
+ CarAppColors getAppColors();
+
+ /** Returns whether this app info is for a navigation app. */
+ boolean isNavigationApp();
+
+ /** Returns a round app icon for the given car app. */
+ @NonNull
+ Drawable getRoundAppIcon();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarColorUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarColorUtils.java
new file mode 100644
index 0000000..bc272f8
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarColorUtils.java
@@ -0,0 +1,379 @@
+/*
+ * 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.common;
+
+import static androidx.car.app.model.CarColor.TYPE_BLUE;
+import static androidx.car.app.model.CarColor.TYPE_CUSTOM;
+import static androidx.car.app.model.CarColor.TYPE_DEFAULT;
+import static androidx.car.app.model.CarColor.TYPE_GREEN;
+import static androidx.car.app.model.CarColor.TYPE_PRIMARY;
+import static androidx.car.app.model.CarColor.TYPE_RED;
+import static androidx.car.app.model.CarColor.TYPE_SECONDARY;
+import static androidx.car.app.model.CarColor.TYPE_YELLOW;
+import static androidx.core.graphics.ColorUtils.calculateContrast;
+import static com.android.car.libraries.apphost.common.ColorUtils.KEY_COLOR_PRIMARY;
+import static com.android.car.libraries.apphost.common.ColorUtils.KEY_COLOR_PRIMARY_DARK;
+import static com.android.car.libraries.apphost.common.ColorUtils.KEY_COLOR_SECONDARY;
+import static com.android.car.libraries.apphost.common.ColorUtils.KEY_COLOR_SECONDARY_DARK;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.util.Pair;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarColor;
+import androidx.car.app.model.CarIcon;
+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;
+
+/** Utilities for handling {@link CarColor} instances. */
+public class CarColorUtils {
+
+ private static final double MINIMUM_COLOR_CONTRAST = 4.5;
+
+ /**
+ * Resolves a standard color to a {@link ColorInt}.
+ *
+ * @return the resolved color or {@code defaultColor} if the input {@code carColor} is {@code
+ * null}, does not meet the constraints, or of the type {@link CarColor#DEFAULT}
+ */
+ @ColorInt
+ public static int resolveColor(
+ TemplateContext templateContext,
+ @Nullable CarColor carColor,
+ boolean isDark,
+ @ColorInt int defaultColor,
+ CarColorConstraints constraints) {
+ return resolveColor(
+ templateContext, carColor, isDark, defaultColor, constraints, Color.TRANSPARENT);
+ }
+
+ /**
+ * Resolves a standard color to a {@link ColorInt}.
+ *
+ * <p>If {@code backgroundColor} is set to {@link Color#TRANSPARENT}, the {@code carColor} will
+ * not be checked for the minimum color contrast.
+ *
+ * @return the resolved color or {@code defaultColor} if the input {@code carColor} is {@code
+ * null}, does not meet the constraints or minimum color contrast, or of the type {@link
+ * CarColor#DEFAULT}
+ */
+ @ColorInt
+ public static int resolveColor(
+ TemplateContext templateContext,
+ @Nullable CarColor carColor,
+ boolean isDark,
+ @ColorInt int defaultColor,
+ CarColorConstraints constraints,
+ @ColorInt int backgroundColor) {
+ if (carColor == null) {
+ return defaultColor;
+ }
+ try {
+ constraints.validateOrThrow(carColor);
+ } catch (IllegalArgumentException e) {
+ L.e(LogTags.TEMPLATE, e, "Validation failed for color %s, will use default", carColor);
+ return defaultColor;
+ }
+
+ CarAppPackageInfo info = templateContext.getCarAppPackageInfo();
+ CarAppColors carAppColors = info.getAppColors();
+ HostResourceIds hostResourceIds = templateContext.getHostResourceIds();
+ return resolveColor(
+ templateContext,
+ isDark,
+ carColor,
+ carAppColors,
+ hostResourceIds,
+ defaultColor,
+ backgroundColor);
+ }
+
+ /** Resolves a standard color to a {@link ColorInt}. */
+ @ColorInt
+ public static int resolveColor(
+ Context context,
+ boolean isDark,
+ @Nullable CarColor carColor,
+ CarAppColors carAppColors,
+ HostResourceIds resIds,
+ @ColorInt int defaultColor,
+ @ColorInt int backgroundColor) {
+ if (carColor == null) {
+ return defaultColor;
+ }
+ int type = carColor.getType();
+ Resources resources = context.getResources();
+ switch (type) {
+ case TYPE_DEFAULT:
+ return defaultColor;
+ case TYPE_PRIMARY:
+ return getContrastCheckedColor(
+ carAppColors.primaryColor,
+ carAppColors.primaryDarkColor,
+ backgroundColor,
+ defaultColor,
+ isDark);
+ case TYPE_SECONDARY:
+ return getContrastCheckedColor(
+ carAppColors.secondaryColor,
+ carAppColors.secondaryDarkColor,
+ backgroundColor,
+ defaultColor,
+ isDark);
+ case TYPE_RED:
+ return resources.getColor(isDark ? resIds.getRedDarkColor() : resIds.getRedColor());
+ case TYPE_GREEN:
+ return resources.getColor(isDark ? resIds.getGreenDarkColor() : resIds.getGreenColor());
+ case TYPE_BLUE:
+ return resources.getColor(isDark ? resIds.getBlueDarkColor() : resIds.getBlueColor());
+ case TYPE_YELLOW:
+ return resources.getColor(isDark ? resIds.getYellowDarkColor() : resIds.getYellowColor());
+ case TYPE_CUSTOM:
+ return getContrastCheckedColor(
+ carColor.getColor(), carColor.getColorDark(), backgroundColor, defaultColor, isDark);
+ default:
+ L.e(LogTags.TEMPLATE, "Failed to resolve standard color id: %d", type);
+ return defaultColor;
+ }
+ }
+
+ /**
+ * Returns the {@link CarAppColors} from the given app name if all primary and secondary colors
+ * are present in the app's manifest, otherwise returns {@link CarAppColors#getDefault(Context,
+ * HostResourceIds)}.
+ */
+ public static CarAppColors resolveAppColor(
+ @NonNull Context context,
+ @NonNull ComponentName appName,
+ @NonNull HostResourceIds hostResourceIds) {
+ String packageName = appName.getPackageName();
+ CarAppColors defaultColors = CarAppColors.getDefault(context, hostResourceIds);
+
+ int themeId = ColorUtils.loadThemeId(context, appName);
+ if (themeId == 0) {
+ L.w(LogTags.TEMPLATE, "Cannot get the app theme from %s", packageName);
+ return defaultColors;
+ }
+
+ Context packageContext = ColorUtils.getPackageContext(context, packageName);
+ if (packageContext == null) {
+ L.w(LogTags.TEMPLATE, "Cannot get the app context from %s", packageName);
+ return defaultColors;
+ }
+ packageContext.setTheme(themeId);
+
+ Resources.Theme theme = packageContext.getTheme();
+ Pair<Integer, Integer> primaryColorVariants =
+ ColorUtils.getColorVariants(
+ theme,
+ packageName,
+ KEY_COLOR_PRIMARY,
+ KEY_COLOR_PRIMARY_DARK,
+ defaultColors.primaryColor,
+ defaultColors.primaryDarkColor);
+ Pair<Integer, Integer> secondaryColorVariants =
+ ColorUtils.getColorVariants(
+ theme,
+ packageName,
+ KEY_COLOR_SECONDARY,
+ KEY_COLOR_SECONDARY_DARK,
+ defaultColors.secondaryColor,
+ defaultColors.secondaryDarkColor);
+
+ return new CarAppColors(
+ primaryColorVariants.first,
+ primaryColorVariants.second,
+ secondaryColorVariants.first,
+ secondaryColorVariants.second);
+ }
+
+ /**
+ * Darkens the given color by a percentage of its brightness.
+ *
+ * @param originalColor the color to change the brightness of
+ * @param percentage the percentage to decrement the brightness for, in the [0..1] range. For
+ * example, a value of 0.5 will make the color 50% less bright
+ */
+ @ColorInt
+ public static int darkenColor(@ColorInt int originalColor, float percentage) {
+ float[] hsv = new float[3];
+ Color.colorToHSV(originalColor, hsv);
+ hsv[2] *= 1.f - percentage;
+ return Color.HSVToColor(hsv);
+ }
+
+ /**
+ * Blends two colors using a SRC Porter-duff operator.
+ *
+ * <p>See <a href="http://ssp.impulsetrain.com/porterduff.html">Porter-Duff Compositing and Blend
+ * Modes</a>
+ *
+ * <p>NOTE: this function ignores the alpha channel of the destination, and returns a fully opaque
+ * color.
+ */
+ @ColorInt
+ public static int blendColorsSrc(@ColorInt int source, @ColorInt int destination) {
+ // Each color component is calculated like so:
+ // output_color = (1 - alpha(source)) * destination + alpha_source * source
+ float alpha = Color.alpha(source) / 255.f;
+ return Color.argb(
+ 255,
+ clampComponent(alpha * Color.red(source) + (1 - alpha) * Color.red(destination)),
+ clampComponent(alpha * Color.green(source) + (1 - alpha) * Color.green(destination)),
+ clampComponent(alpha * Color.blue(source) + (1 - alpha) * Color.blue(destination)));
+ }
+
+ /**
+ * Checks whether the given colors provide an acceptable contrast ratio.
+ *
+ * <p>See <a href="https://material.io/design/usability/accessibility.html#color-and-contrast">
+ * Color and Contrast</a>
+ *
+ * <p>If {@code backgroundColor} is {@link Color#TRANSPARENT}, any {@code foregroundColor} will
+ * pass the check.
+ *
+ * @param foregroundColor the foreground color for which the contrast should be checked.
+ * @param backgroundColor the background color for which the contrast should be checked.
+ * @return true if placing the foreground color over the background color results in an acceptable
+ * contrast.
+ */
+ public static boolean hasMinimumColorContrast(
+ @ColorInt int foregroundColor, @ColorInt int backgroundColor) {
+ if (backgroundColor == Color.TRANSPARENT) {
+ return true;
+ }
+
+ return calculateContrast(foregroundColor, backgroundColor) > MINIMUM_COLOR_CONTRAST;
+ }
+
+ /**
+ * Check if any variant in the given {@code foregroundCarColor} has enough color contrast against
+ * the given {@code backgroundColor}.
+ */
+ public static boolean checkColorContrast(
+ TemplateContext templateContext, CarColor foregroundCarColor, @ColorInt int backgroundColor) {
+ if (backgroundColor == Color.TRANSPARENT) {
+ return true;
+ }
+
+ if (CarColor.DEFAULT.equals(foregroundCarColor)) {
+ return true;
+ }
+
+ CarColor foregroundColor = convertToCustom(templateContext, foregroundCarColor);
+ boolean checkPasses =
+ hasMinimumColorContrast(foregroundColor.getColor(), backgroundColor)
+ || hasMinimumColorContrast(foregroundColor.getColorDark(), backgroundColor);
+ if (!checkPasses) {
+ L.w(
+ LogTags.TEMPLATE,
+ "Color contrast check failed, foreground car color: %s, background color: %d",
+ foregroundCarColor,
+ backgroundColor);
+ templateContext.getColorContrastCheckState().setCheckPassed(false);
+ }
+ return checkPasses;
+ }
+
+ /**
+ * Returns whether the icon's tint passes the color contrast check against the given background
+ * color.
+ */
+ public static boolean checkIconTintContrast(
+ TemplateContext templateContext, @Nullable CarIcon icon, @ColorInt int backgroundColor) {
+ boolean passes = true;
+ if (icon != null) {
+ CarColor iconTint = icon.getTint();
+ if (iconTint != null) {
+ passes = checkColorContrast(templateContext, iconTint, backgroundColor);
+ }
+ }
+ return passes;
+ }
+
+ /**
+ * Convert the given {@code carColor} into a {@link CarColor} of type {@link
+ * CarColor#TYPE_CUSTOM}.
+ */
+ private static CarColor convertToCustom(TemplateContext templateContext, CarColor carColor) {
+ if (carColor.getType() == TYPE_CUSTOM) {
+ return carColor;
+ }
+
+ @ColorInt
+ int color =
+ resolveColor(
+ templateContext,
+ carColor,
+ /* isDark= */ false,
+ Color.TRANSPARENT,
+ CarColorConstraints.UNCONSTRAINED);
+ @ColorInt
+ int colorDark =
+ resolveColor(
+ templateContext,
+ carColor,
+ /* isDark= */ true,
+ Color.TRANSPARENT,
+ CarColorConstraints.UNCONSTRAINED);
+ return CarColor.createCustom(color, colorDark);
+ }
+
+ /**
+ * Between the given {@code color} and {@code colorDark}, returns the color that has enough color
+ * contrast against the given {@code backgroundColor}.
+ *
+ * <p>If none of the given colors passes the check, returns {@code defaultColor}.
+ *
+ * <p>If {@code isDark} is {@code true}, {@code colorDark} will be checked first, otherwise {@code
+ * color} will be checked first. The first color passes the check will be returned.
+ */
+ @ColorInt
+ private static int getContrastCheckedColor(
+ @ColorInt int color,
+ @ColorInt int colorDark,
+ @ColorInt int backgroundColor,
+ @ColorInt int defaultColor,
+ boolean isDark) {
+ int[] colors = new int[2];
+ if (isDark) {
+ colors[0] = colorDark;
+ colors[1] = color;
+ } else {
+ colors[0] = color;
+ colors[1] = colorDark;
+ }
+
+ for (@ColorInt int col : colors) {
+ if (hasMinimumColorContrast(col, backgroundColor)) {
+ return col;
+ }
+ }
+ return defaultColor;
+ }
+
+ private static int clampComponent(float color) {
+ return (int) Math.max(0, Math.min(255, color));
+ }
+
+ private CarColorUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarHostConfig.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarHostConfig.java
new file mode 100644
index 0000000..8bd4ac6
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CarHostConfig.java
@@ -0,0 +1,203 @@
+/*
+ * 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.common;
+
+import static androidx.annotation.VisibleForTesting.PROTECTED;
+import static java.lang.Math.min;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.AppInfo;
+import androidx.car.app.versioning.CarAppApiLevels;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.logging.StatusReporter;
+import java.io.PrintWriter;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+
+/** Configuration options from the car host. */
+public abstract class CarHostConfig implements StatusReporter {
+
+ /** Represent the OEMs' preference on ordering the primary action */
+ @IntDef(
+ value = {
+ PRIMARY_ACTION_HORIZONTAL_ORDER_NOT_SET,
+ PRIMARY_ACTION_HORIZONTAL_ORDER_LEFT,
+ PRIMARY_ACTION_HORIZONTAL_ORDER_RIGHT,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface PrimaryActionOrdering {}
+
+ /** Indicates that OEMs choose to not re-ordering the actions */
+ public static final int PRIMARY_ACTION_HORIZONTAL_ORDER_NOT_SET = 0;
+
+ /** Indicates that OEMs choose to put the primary action on the left */
+ public static final int PRIMARY_ACTION_HORIZONTAL_ORDER_LEFT = 1;
+
+ /** Indicates that OEMs choose to put the primary action on the right */
+ public static final int PRIMARY_ACTION_HORIZONTAL_ORDER_RIGHT = 2;
+
+ private final ComponentName mAppName;
+ // Default to oldest as the min communication until updated via a call to negotiateApi.
+ // The oldest is the default lowest common denominator for communication.
+ private int mNegotiatedApi = CarAppApiLevels.getOldest();
+ // Last received app info, used for debugging purposes. This is the information the above
+ // negotiated API level is based on.
+ @Nullable private AppInfo mAppInfo = null;
+
+ public CarHostConfig(ComponentName appName) {
+ mAppName = appName;
+ }
+
+ /**
+ * Returns how many seconds after the user leaves an app, should the system wait before unbinding
+ * from it.
+ */
+ public abstract int getAppUnbindSeconds();
+
+ /** Returns a list of intent extras to be stripped before binding to the client app. */
+ public abstract List<String> getHostIntentExtrasToRemove();
+
+ /** Returns whether the provided intent should be treated as a new task flow. */
+ public abstract boolean isNewTaskFlowIntent(Intent intent);
+
+ /**
+ * Updates the API level for communication between the host and the connecting app.
+ *
+ * @return the negotiated api
+ * @throws IncompatibleApiException if the app's supported API range does not work with the host's
+ * API range
+ */
+ public int updateNegotiatedApi(AppInfo appInfo) throws IncompatibleApiException {
+ mAppInfo = appInfo;
+ int appMinApi = mAppInfo.getMinCarAppApiLevel();
+ int appMaxApi = mAppInfo.getLatestCarAppApiLevel();
+ int hostMinApi = getHostMinApi();
+ int hostMaxApi = getHostMaxApi();
+
+ L.i(
+ LogTags.APP_HOST,
+ "App: [%s] app info: [%s] Host min api: [%d] Host max api: [%d]",
+ mAppName.flattenToShortString(),
+ mAppInfo,
+ hostMinApi,
+ hostMaxApi);
+
+ if (appMinApi > hostMaxApi) {
+ throw new IncompatibleApiException(
+ ApiIncompatibilityType.HOST_TOO_OLD,
+ "App required min API level ["
+ + appMinApi
+ + "] is higher than the host's max API level ["
+ + hostMaxApi
+ + "]");
+ } else if (hostMinApi > appMaxApi) {
+ throw new IncompatibleApiException(
+ ApiIncompatibilityType.APP_TOO_OLD,
+ "Host required min API level ["
+ + hostMinApi
+ + "] is higher than the app's max API level ["
+ + appMaxApi
+ + "]");
+ }
+
+ mNegotiatedApi = min(appMaxApi, hostMaxApi);
+ L.d(
+ LogTags.APP_HOST,
+ "App: [%s], Host negotiated api: [%d]",
+ mAppName.flattenToShortString(),
+ mNegotiatedApi);
+
+ return mNegotiatedApi;
+ }
+
+ /** Returns the {@link AppInfo} that was last set, or {@code null} otherwise. */
+ @Nullable
+ public AppInfo getAppInfo() {
+ return mAppInfo;
+ }
+
+ /**
+ * Returns the API that was negotiated between the host and the connecting app. The host should
+ * use this value to determine if a feature for a particular API is supported for the app.
+ */
+ public int getNegotiatedApi() {
+ return mNegotiatedApi;
+ }
+
+ @Override
+ public void reportStatus(PrintWriter pw, Pii piiHandling) {
+ pw.printf(
+ "- host min api: %d, host max api: %d, negotiated api: %s\n",
+ getHostMinApi(), getHostMaxApi(), mNegotiatedApi);
+ pw.printf(
+ "- app min api: %s, app target api: %s\n",
+ mAppInfo != null ? mAppInfo.getMinCarAppApiLevel() : "-",
+ mAppInfo != null ? mAppInfo.getLatestCarAppApiLevel() : "-");
+ pw.printf(
+ "- sdk version: %s\n", mAppInfo != null ? mAppInfo.getLibraryDisplayVersion() : "n/a");
+ }
+
+ /**
+ * Returns the host minimum API supported for the app.
+ *
+ * <p>Depending on the connecting app, the host may be configured to use a higher API level than
+ * the lowest level that the host is capable of supporting.
+ */
+ @VisibleForTesting(otherwise = PROTECTED)
+ public abstract int getHostMinApi();
+
+ /**
+ * Returns the host maximum API supported for the app.
+ *
+ * <p>Depending on the connecting app, the host may be configured to use a lower API level than
+ * the highest level that the host is capable of supporting.
+ */
+ @VisibleForTesting(otherwise = PROTECTED)
+ public abstract int getHostMaxApi();
+
+ /** Returns whether oem choose to ignore app provided colors on buttons on select templates. */
+ public abstract boolean isButtonColorOverriddenByOEM();
+
+ /**
+ * Returns the primary action order
+ *
+ * <p>Depending on the OEMs config, the primary action can be placed on the right or left,
+ * regardless of the config from connection app.
+ *
+ * @see PrimaryActionOrdering
+ */
+ @PrimaryActionOrdering
+ public abstract int getPrimaryActionOrder();
+
+ /** Returns true if the host supports cluster activity */
+ public abstract boolean isClusterEnabled();
+
+ /** Returns whether the host supports pan and zoom features in the navigation template */
+ public abstract boolean isNavPanZoomEnabled();
+
+ /** Returns whether the host supports pan and zoom features in POI and route preview templates */
+ public abstract boolean isPoiRoutePreviewPanZoomEnabled();
+
+ /** Returns whether the host supports pan and zoom features in POI and route preview templates */
+ public abstract boolean isPoiContentRefreshEnabled();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorContrastCheckState.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorContrastCheckState.java
new file mode 100644
index 0000000..ff5b0e2
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorContrastCheckState.java
@@ -0,0 +1,34 @@
+/*
+ * 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.common;
+
+/**
+ * Manages the state of color contrast checks in template apps.
+ *
+ * <p>This class tracks the state for a single template in a single app.
+ */
+public interface ColorContrastCheckState {
+ /** Sets whether the color contrast check passed in the current template. */
+ void setCheckPassed(boolean passed);
+
+ /** Returns whether the color contrast check passed in the current template. */
+ boolean getCheckPassed();
+
+ /** Returns whether the host checks color contrast. */
+ // TODO(b/208683313): Remove once color contrast check is enabled in AAP
+ boolean checksContrast();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorUtils.java
new file mode 100644
index 0000000..6356664
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ColorUtils.java
@@ -0,0 +1,165 @@
+/*
+ * 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.common;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ServiceInfo;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.util.Pair;
+import androidx.annotation.ColorInt;
+import androidx.annotation.Nullable;
+import androidx.annotation.StyleRes;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+
+/** Utility class to load a car app's primary and secondary colors. */
+public final class ColorUtils {
+ private static final String KEY_THEME = "androidx.car.app.theme";
+
+ // LINT.IfChange(car_colors)
+ public static final String KEY_COLOR_PRIMARY = "carColorPrimary";
+ public static final String KEY_COLOR_PRIMARY_DARK = "carColorPrimaryDark";
+ public static final String KEY_COLOR_SECONDARY = "carColorSecondary";
+ public static final String KEY_COLOR_SECONDARY_DARK = "carColorSecondaryDark";
+ // LINT.ThenChange()
+
+ private ColorUtils() {}
+
+ /** Returns a {@link Context} set up for the given package. */
+ @Nullable
+ public static Context getPackageContext(Context context, String packageName) {
+ Context packageContext;
+ try {
+ packageContext = context.createPackageContext(packageName, /* flags= */ 0);
+ } catch (PackageManager.NameNotFoundException e) {
+ L.e(LogTags.APP_HOST, e, "Package %s does not exist", packageName);
+ return null;
+ }
+ return packageContext;
+ }
+
+ /**
+ * Returns the ID of the theme to use for the app described by the given component name.
+ *
+ * <p>This theme id is used to load custom primary and secondary colors from the remote app.
+ *
+ * @see com.google.android.libraries.car.app.model.CarColor
+ */
+ @StyleRes
+ public static int loadThemeId(Context context, ComponentName componentName) {
+ int theme = 0;
+ ServiceInfo serviceInfo = getServiceInfo(context, componentName);
+ if (serviceInfo != null && serviceInfo.metaData != null) {
+ theme = serviceInfo.metaData.getInt(KEY_THEME);
+ }
+
+ // If theme is not specified in service information, fallback to KEY_THEME in application
+ // info.
+ if (theme == 0) {
+ ApplicationInfo applicationInfo = getApplicationInfo(context, componentName);
+ if (applicationInfo != null) {
+ if (applicationInfo.metaData != null) {
+ theme = applicationInfo.metaData.getInt(KEY_THEME);
+ }
+ // If no override provided in service and application info, fallback to default app
+ // theme.
+ if (theme == 0) {
+ theme = applicationInfo.theme;
+ }
+ }
+ }
+
+ return theme;
+ }
+
+ /**
+ * Returns the color values for the given light and dark variants.
+ *
+ * <p>If a variant is not specified in the theme, default values are returned for both variants.
+ */
+ public static Pair<Integer, Integer> getColorVariants(
+ Resources.Theme appTheme,
+ String packageName,
+ String colorKey,
+ String darkColorKey,
+ @ColorInt int defaultColor,
+ @ColorInt int defaultDarkColor) {
+ Resources appResources = appTheme.getResources();
+ int colorId = appResources.getIdentifier(colorKey, "attr", packageName);
+ int darkColorId = appResources.getIdentifier(darkColorKey, "attr", packageName);
+
+ // If light or dark variant is not specified, return default variants.
+ if (colorId == Resources.ID_NULL || darkColorId == Resources.ID_NULL) {
+ return new Pair<>(defaultColor, defaultDarkColor);
+ }
+
+ @ColorInt int color = getColor(colorId, /* defaultColor= */ Color.TRANSPARENT, appTheme);
+ @ColorInt
+ int darkColor = getColor(darkColorId, /* defaultColor= */ Color.TRANSPARENT, appTheme);
+
+ // Even if the resource ID exists for a variant, it may not have a value. If so, use default
+ // variants.
+ if (color == Color.TRANSPARENT || darkColor == Color.TRANSPARENT) {
+ return new Pair<>(defaultColor, defaultDarkColor);
+ }
+ return new Pair<>(color, darkColor);
+ }
+
+ /** Returns the color specified by the given resource id from the given app theme. */
+ @ColorInt
+ private static int getColor(int resId, @ColorInt int defaultColor, Resources.Theme appTheme) {
+ @ColorInt int color = defaultColor;
+ if (resId != Resources.ID_NULL) {
+ int[] attr = {resId};
+ TypedArray ta = appTheme.obtainStyledAttributes(attr);
+ color = ta.getColor(0, defaultColor);
+ ta.recycle();
+ }
+ return color;
+ }
+
+ @Nullable
+ private static ServiceInfo getServiceInfo(Context context, ComponentName componentName) {
+ try {
+ return context
+ .getPackageManager()
+ .getServiceInfo(componentName, PackageManager.GET_META_DATA);
+ } catch (PackageManager.NameNotFoundException e) {
+ L.e(LogTags.APP_HOST, e, "Component %s doesn't exist", componentName);
+ }
+
+ return null;
+ }
+
+ @Nullable
+ private static ApplicationInfo getApplicationInfo(Context context, ComponentName componentName) {
+ try {
+ return context
+ .getPackageManager()
+ .getApplicationInfo(componentName.getPackageName(), PackageManager.GET_META_DATA);
+ } catch (PackageManager.NameNotFoundException e) {
+ L.e(LogTags.APP_HOST, e, "Package %s doesn't exist", componentName.getPackageName());
+ }
+
+ return null;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CommonUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CommonUtils.java
new file mode 100644
index 0000000..8733cf7
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/CommonUtils.java
@@ -0,0 +1,54 @@
+/*
+ * 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.common;
+
+import android.content.res.Configuration;
+import android.widget.Toast;
+import androidx.car.app.model.OnClickDelegate;
+
+/** Holds static util methods for common usage in the host. */
+public final class CommonUtils {
+
+ /**
+ * Checks if {@code onClickDelegate} is a parked only action and the car is driving, then shows a
+ * toast and returns. Otherwise dispatches the {@code onClick} to the client.
+ */
+ public static void dispatchClick(
+ TemplateContext templateContext, OnClickDelegate onClickDelegate) {
+ if (onClickDelegate.isParkedOnly()
+ && templateContext.getConstraintsProvider().isConfigRestricted()) {
+ templateContext
+ .getToastController()
+ .showToast(
+ templateContext
+ .getResources()
+ .getString(templateContext.getHostResourceIds().getParkedOnlyActionText()),
+ Toast.LENGTH_SHORT);
+ return;
+ }
+ templateContext.getAppDispatcher().dispatchClick(onClickDelegate);
+ }
+
+ /** Returns {@code true} if the host is in dark mode, {@code false} otherwise. */
+ public static boolean isDarkMode(TemplateContext templateContext) {
+ Configuration configuration = templateContext.getResources().getConfiguration();
+ return (configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK)
+ == Configuration.UI_MODE_NIGHT_YES;
+ }
+
+ private CommonUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/DebugOverlayHandler.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/DebugOverlayHandler.java
new file mode 100644
index 0000000..44b02d2
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/DebugOverlayHandler.java
@@ -0,0 +1,71 @@
+/*
+ * 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.common;
+
+import androidx.annotation.MainThread;
+import androidx.car.app.model.TemplateWrapper;
+
+/**
+ * The interface for forwarding custom debug overlay information to the host fragment or activity.
+ */
+@MainThread
+public interface DebugOverlayHandler {
+ /**
+ * Returns {@code true} if the debug overlay is active.
+ *
+ * <p>The caller can use the active state to determine whether to process debug overlay
+ * information or not.
+ */
+ boolean isActive();
+
+ /**
+ * Sets debug overlay as active/inactive if parameter is {@code true}/{@code false} respectively.
+ */
+ void setActive(boolean active);
+
+ /** Clears all existing debug overlay. */
+ void clearAllEntries();
+
+ /**
+ * Removes the debug overlay entry associated with the input {@code debugKey}.
+ *
+ * <p>If the {@code debugKey} is not associated with any existing entry, this call is a no-op.
+ */
+ void removeDebugOverlayEntry(String debugKey);
+
+ /**
+ * Updates the debug overlay entry associated with a given {@code debugKey}.
+ *
+ * <p>This would override any previous debug text for the same key.
+ */
+ void updateDebugOverlayEntry(String debugKey, String debugOverlayText);
+
+ /** Returns text to render for debug overlay. */
+ CharSequence getDebugOverlayText();
+
+ /** Resets debug overlay with new information from {@link TemplateWrapper} */
+ void resetTemplateDebugOverlay(TemplateWrapper templateWrapper);
+
+ /** Set {@link Observer} for this {@link DebugOverlayHandler} */
+ void setObserver(Observer observer);
+
+ /**
+ * The interface that lets an object observe changes to the {@link DebugOverlayHandler}'s entries.
+ */
+ interface Observer {
+ void entriesUpdated();
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorHandler.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorHandler.java
new file mode 100644
index 0000000..fc30645
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorHandler.java
@@ -0,0 +1,25 @@
+/*
+ * 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.common;
+
+/**
+ * Handles error cases, allowing classes that do not handle ui to be able to display an error screen
+ * to the user.
+ */
+public interface ErrorHandler {
+ /** Displays the given an error screen to the user. */
+ void showError(CarAppError error);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorMessageTemplateBuilder.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorMessageTemplateBuilder.java
new file mode 100644
index 0000000..51aafee
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ErrorMessageTemplateBuilder.java
@@ -0,0 +1,157 @@
+/*
+ * 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.common;
+
+import static androidx.car.app.model.CarIcon.ERROR;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.Action;
+import androidx.car.app.model.MessageTemplate;
+import androidx.car.app.model.OnClickListener;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+
+/**
+ * Formats {@link CarAppError} into {@link MessageTemplate} to allow displaying the error to the
+ * user.
+ */
+public class ErrorMessageTemplateBuilder {
+ private final Context mContext;
+ private final HostResourceIds mHostResourceIds;
+ private final CarAppError mError;
+ private final ComponentName mAppName;
+ private final OnClickListener mMainActionOnClickListener;
+
+ private String mAppLabel;
+
+ /** Constructor of an {@link ErrorMessageTemplateBuilder} */
+ @SuppressWarnings("nullness")
+ public ErrorMessageTemplateBuilder(
+ @NonNull Context context,
+ @NonNull CarAppError error,
+ @NonNull HostResourceIds instance,
+ @NonNull OnClickListener listener) {
+
+ if (context == null || error == null || instance == null || listener == null) {
+ throw new NullPointerException();
+ }
+
+ mContext = context;
+ mError = error;
+ mAppName = error.getAppName();
+ mHostResourceIds = instance;
+ mMainActionOnClickListener = listener;
+ }
+
+ /** Returns an {@link ErrorMessageTemplateBuilder} with {@link String} appLabel */
+ @NonNull
+ public ErrorMessageTemplateBuilder setAppLabel(String appLabel) {
+ mAppLabel = appLabel;
+ return this;
+ }
+
+ /** Returns a {@link MessageTemplate} with error message */
+ public MessageTemplate build() {
+ if (mAppLabel == null) {
+ PackageManager pm = mContext.getPackageManager();
+ ApplicationInfo applicationInfo = null;
+ try {
+ applicationInfo = pm.getApplicationInfo(mAppName.getPackageName(), 0);
+ } catch (NameNotFoundException e) {
+ L.e(LogTags.TEMPLATE, e, "Could not find the application info");
+ }
+ mAppLabel =
+ applicationInfo == null
+ ? mAppName.getPackageName()
+ : pm.getApplicationLabel(applicationInfo).toString();
+ }
+ String errorMessage = getErrorMessage(mAppLabel, mError);
+ if (errorMessage == null) {
+ errorMessage = mContext.getString(mHostResourceIds.getClientErrorText(), mAppLabel);
+ }
+
+ // TODO(b/179320446): Note that we use a whitespace as the title to not show anything in
+ // the header. We will have to update this to some internal-only template once the
+ // whitespace string no longer supperted.
+ MessageTemplate.Builder messageTemplateBuilder =
+ new MessageTemplate.Builder(errorMessage).setTitle(" ").setIcon(ERROR);
+
+ Throwable cause = mError.getCause();
+ if (cause != null) {
+ messageTemplateBuilder.setDebugMessage(cause);
+ }
+
+ String debugMessage = mError.getDebugMessage();
+ if (debugMessage != null) {
+ messageTemplateBuilder.setDebugMessage(debugMessage);
+ }
+
+ messageTemplateBuilder.addAction(
+ new Action.Builder()
+ .setTitle(mContext.getString(mHostResourceIds.getExitText()))
+ .setOnClickListener(mMainActionOnClickListener)
+ .build());
+
+ Action extraAction = getExtraAction(mError.getType(), mError.getExtraAction());
+ if (extraAction != null) {
+ messageTemplateBuilder.addAction(extraAction);
+ }
+
+ return messageTemplateBuilder.build();
+ }
+
+ @Nullable
+ private String getErrorMessage(String appLabel, @Nullable CarAppError error) {
+ CarAppError.Type type = error == null ? null : error.getType();
+ if (error == null || type == null) {
+ return null;
+ }
+ switch (type) {
+ case ANR_TIMEOUT:
+ return mContext.getString(mHostResourceIds.getAnrMessage(), appLabel);
+ case ANR_WAITING:
+ return mContext.getString(mHostResourceIds.getAnrWaiting());
+ case INCOMPATIBLE_CLIENT_VERSION:
+ ApiIncompatibilityType apiIncompatibilityType = ApiIncompatibilityType.HOST_TOO_OLD;
+ Throwable exception = error.getCause();
+ if (exception instanceof IncompatibleApiException) {
+ apiIncompatibilityType = ((IncompatibleApiException) exception).getIncompatibilityType();
+ }
+ return mContext.getString(
+ mHostResourceIds.getAppApiIncompatibleText(apiIncompatibilityType), appLabel);
+ case MISSING_PERMISSION:
+ return mContext.getString(mHostResourceIds.getMissingPermissionText(), appLabel);
+ }
+ throw new IllegalArgumentException("Unknown error type: " + type);
+ }
+
+ @Nullable
+ private Action getExtraAction(@Nullable CarAppError.Type type, @Nullable Runnable extraAction) {
+ if (type != CarAppError.Type.ANR_TIMEOUT || extraAction == null) {
+ return null;
+ }
+ return new Action.Builder()
+ .setTitle(mContext.getString(mHostResourceIds.getAnrWait()))
+ .setOnClickListener(extraAction::run)
+ .build();
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/EventManager.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/EventManager.java
new file mode 100644
index 0000000..b9a78f5
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/EventManager.java
@@ -0,0 +1,139 @@
+/*
+ * 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.common;
+
+import android.content.res.Resources;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.WeakHashMap;
+
+/** Handles event dispatch and subscription. */
+public class EventManager {
+ /** The type of events. */
+ public enum EventType {
+ /** Unknown event type */
+ UNKNOWN,
+
+ /** Signifies that the visible area of the view has changed. */
+ SURFACE_VISIBLE_AREA,
+
+ /** Signifies that the stable area of the view has changed. */
+ SURFACE_STABLE_AREA,
+
+ /**
+ * Signifies that one of the descendants of the template view hierarchy has been interacted
+ * with.
+ */
+ TEMPLATE_TOUCHED_OR_FOCUSED,
+
+ /** Signifies that the focus state of the window that contains the template view has changed. */
+ WINDOW_FOCUS_CHANGED,
+
+ /** Signifies that the Car UX Restrictions constraints on the template view have changed. */
+ CONSTRAINTS,
+
+ /**
+ * Signifies that the configuration of the view has changed.
+ *
+ * <p>The most up-to-date configuration can be retrieved via {@link
+ * Resources#getConfiguration()}
+ */
+ CONFIGURATION_CHANGED,
+
+ /** Signifies that the app is now unbound. */
+ APP_UNBOUND,
+
+ /** Signifies that the app has disconnected and will be rebound. */
+ APP_DISCONNECTED,
+
+ /**
+ * Signifies that the current list of places has changed.
+ *
+ * <p>This is used by the PlaceListMapTemplate to synchronize places between the list and the
+ * map views.
+ */
+ PLACE_LIST,
+
+ /** Signifies that WindowInsets has changed. */
+ WINDOW_INSETS,
+ }
+
+ // A weak-referenced map is used here so that subscribers do not have to explicitly unsubscribe
+ // themselves.
+ private final WeakHashMap<Object, List<Dependency>> mDependencyMap = new WeakHashMap<>();
+
+ /**
+ * Subscribes to an {@link EventType} and trigger the given {@link Runnable} when the event is
+ * fired.
+ *
+ * <p>The input weakReference instance should be used to associate and clean up the {@link
+ * Runnable} so that the event subscriber will automatically unsubscribe itself when the
+ * weak-referenced object is GC'd. However, if earlier un-subscription is preferred, {@link
+ * #unsubscribeEvent} can be called instead.
+ */
+ public void subscribeEvent(Object weakReference, EventType eventType, Runnable runnable) {
+ List<Dependency> objectDependencies = mDependencyMap.get(weakReference);
+ if (objectDependencies == null) {
+ objectDependencies = new ArrayList<>();
+ mDependencyMap.put(weakReference, objectDependencies);
+ }
+ objectDependencies.add(new Dependency(eventType, runnable));
+ }
+
+ /** Unsubscribes the given object (weakReference) to a certain {@link EventType}. */
+ public void unsubscribeEvent(Object weakReference, EventType eventType) {
+ List<Dependency> objectDependencies = mDependencyMap.get(weakReference);
+ if (objectDependencies != null) {
+ Iterator<Dependency> itr = objectDependencies.iterator();
+ while (itr.hasNext()) {
+ Dependency dependency = itr.next();
+ if (dependency.mEventType == eventType) {
+ itr.remove();
+ }
+ }
+ }
+ }
+
+ /** Dispatches the given {@link EventType} so subscriber can react to it. */
+ public void dispatchEvent(EventType eventType) {
+ // TODO(b/163634344): Avoid creating a temp collection. This is needed to prevent concurrent
+ // modifications that could happen if something subscribe to an event while
+ // listening/handling
+ // an existing event.
+ Collection<List<Dependency>> dependencySet = new ArrayList<>(mDependencyMap.values());
+ for (List<Dependency> dependencies : dependencySet) {
+ for (Dependency dependency : dependencies) {
+ if (dependency.mEventType == eventType) {
+ dependency.mRunnable.run();
+ }
+ }
+ }
+ }
+
+ /** An internal container for associating an {@link EventType} with a {@link Runnable}. */
+ private static class Dependency {
+ private final EventType mEventType;
+ private final Runnable mRunnable;
+
+ Dependency(EventType eventType, Runnable runnable) {
+ mEventType = eventType;
+ mRunnable = runnable;
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/HostResourceIds.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/HostResourceIds.java
new file mode 100644
index 0000000..4bc2210
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/HostResourceIds.java
@@ -0,0 +1,215 @@
+/*
+ * 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.common;
+
+import androidx.annotation.ColorRes;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+
+/**
+ * Host-dependent resource identifiers.
+ *
+ * <p>Given that each host will have its own set of resources, this interface abstracts out the
+ * exact resource needed in each case.
+ */
+public interface HostResourceIds {
+
+ /** Returns the resource ID of drawable for the alert icon. */
+ @DrawableRes
+ int getAlertIconDrawable();
+
+ /** Returns the resource ID of the drawable for the error icon. */
+ @DrawableRes
+ int getErrorIconDrawable();
+
+ /** Returns the resource ID of the drawable for the error icon. */
+ @DrawableRes
+ int getBackIconDrawable();
+
+ /** Returns the resource ID of the drawable for the pan icon. */
+ @DrawableRes
+ int getPanIconDrawable();
+
+ /** Returns the resource ID of drawable for the refresh icon. */
+ @DrawableRes
+ int getRefreshIconDrawable();
+
+ /** Returns the resource ID of the standard red color. */
+ @ColorRes
+ int getRedColor();
+
+ /** Returns the resource ID of the standard red color's dark variant. */
+ @ColorRes
+ int getRedDarkColor();
+
+ /** Returns the resource ID of the standard green color. */
+ @ColorRes
+ int getGreenColor();
+
+ /** Returns the resource ID of the standard red color's dark variant. */
+ @ColorRes
+ int getGreenDarkColor();
+
+ /** Returns the resource ID of the standard blue color. */
+ @ColorRes
+ int getBlueColor();
+
+ /** Returns the resource ID of the standard red color's dark variant. */
+ @ColorRes
+ int getBlueDarkColor();
+
+ /** Returns the resource ID of the standard yellow color. */
+ @ColorRes
+ int getYellowColor();
+
+ /** Returns the resource ID of the standard red color's dark variant. */
+ @ColorRes
+ int getYellowDarkColor();
+
+ /**
+ * Returns the resource ID of the default color to use for the standard primary color, unless
+ * specified by the app.
+ */
+ @ColorRes
+ int getDefaultPrimaryColor();
+
+ /**
+ * Returns the resource ID of the default color to use for the standard primary color, unless
+ * specified by the app, in its dark variant.
+ */
+ @ColorRes
+ int getDefaultPrimaryDarkColor();
+
+ /**
+ * Returns the resource ID of the default color to use for the standard secondary color, unless
+ * specified by the app.
+ */
+ @ColorRes
+ int getDefaultSecondaryColor();
+
+ /**
+ * Returns the resource ID of the default color to use for the standard secondary color, unless
+ * specified by the app, in its dark variant.
+ */
+ @ColorRes
+ int getDefaultSecondaryDarkColor();
+
+ /** Returns the resource ID of the string used to format a distance in meters. */
+ @StringRes
+ int getDistanceInMetersStringFormat();
+
+ /** Returns the resource ID of the string used to format a distance in kilometers. */
+ @StringRes
+ int getDistanceInKilometersStringFormat();
+
+ /** Returns the resource ID of the string used to format a distance in feet. */
+ @StringRes
+ int getDistanceInFeetStringFormat();
+
+ /** Returns the resource ID of the string used to format a distance in miles. */
+ @StringRes
+ int getDistanceInMilesStringFormat();
+
+ /** Returns the resource ID of the string used to format a distance in yards. */
+ @StringRes
+ int getDistanceInYardsStringFormat();
+
+ /** Returns the resource ID of the string used to format a time with a time zone string. */
+ @StringRes
+ int getTimeAtDestinationWithTimeZoneStringFormat();
+
+ /** Returns the resource ID of the string used to format a duration in days. */
+ @StringRes
+ int getDurationInDaysStringFormat();
+
+ /** Returns the resource ID of the string used to format a duration in days and hours. */
+ @StringRes
+ int getDurationInDaysAndHoursStringFormat();
+
+ /** Returns the resource ID of the string used to format a duration in hours. */
+ @StringRes
+ int getDurationInHoursStringFormat();
+
+ /** Returns the resource ID of the string used to format a duration in hours and minutes. */
+ @StringRes
+ int getDurationInHoursAndMinutesStringFormat();
+
+ /** Returns the resource ID of the string used to format a duration in minutes. */
+ @StringRes
+ int getDurationInMinutesStringFormat();
+
+ /** Returns the resource ID of the error message for client app exception */
+ @StringRes
+ int getAnrMessage();
+
+ /** Returns the resource ID of the button text for waiting for ANR */
+ @StringRes
+ int getAnrWait();
+
+ /** Returns the resource ID of the error message for waiting for application to respond */
+ @StringRes
+ int getAnrWaiting();
+
+ /**
+ * Returns the resource ID of the error message for client version check failure of the given
+ * {@link ApiIncompatibilityType}
+ */
+ @StringRes
+ int getAppApiIncompatibleText(@NonNull ApiIncompatibilityType apiIncompatibilityType);
+
+ /** Returns the resource ID of the error message for client app exception */
+ @StringRes
+ int getClientErrorText();
+
+ /**
+ * Returns the resource ID of the error message for the application not having required permission
+ */
+ @StringRes
+ int getMissingPermissionText();
+
+ /** Returns the resource ID of the error message for client app exception */
+ @StringRes
+ int getExitText();
+
+ /**
+ * Returns the resource ID of the toast message for user selecting action that can only be
+ * selected when parked
+ */
+ @StringRes
+ int getParkedOnlyActionText();
+
+ /** Returns the resource ID of the search hint */
+ @StringRes
+ int getSearchHintText();
+
+ /** Returns the resource ID of the disabled search hint */
+ @StringRes
+ int getSearchHintDisabledText();
+
+ /** Returns the resource ID of the message for driving state */
+ @StringRes
+ int getDrivingStateMessageText();
+
+ /** Returns the resource ID of the message for no item for the current list */
+ @StringRes
+ int getTemplateListNoItemsText();
+
+ /** Returns the resource ID of the message for disabled action in long message template */
+ @StringRes
+ int getLongMessageTemplateDisabledActionText();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IncompatibleApiException.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IncompatibleApiException.java
new file mode 100644
index 0000000..e0629f6
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IncompatibleApiException.java
@@ -0,0 +1,32 @@
+/*
+ * 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.common;
+
+/** An exception for API incompatibility between the host and the connecting app. */
+public final class IncompatibleApiException extends Exception {
+
+ private final ApiIncompatibilityType mApiIncompatibilityType;
+
+ public IncompatibleApiException(ApiIncompatibilityType apiIncompatibilityType, String message) {
+ super(message);
+ mApiIncompatibilityType = apiIncompatibilityType;
+ }
+
+ public ApiIncompatibilityType getIncompatibilityType() {
+ return mApiIncompatibilityType;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IntentUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IntentUtils.java
new file mode 100644
index 0000000..d45618e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/IntentUtils.java
@@ -0,0 +1,55 @@
+/*
+ * 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.common;
+
+import android.content.Intent;
+import androidx.annotation.NonNull;
+import java.util.List;
+
+/** Holds static util methods for host Intent manipulations. */
+public final class IntentUtils {
+ private IntentUtils() {}
+
+ private static final String EXTRA_ORIGINAL_INTENT_KEY =
+ "com.android.car.libraries.apphost.common.ORIGINAL_INTENT";
+
+ /** Embeds {@code originalIntent} inside {@code wrappingIntent} for later extraction. */
+ public static void embedOriginalIntent(
+ @NonNull Intent wrappingIntent, @NonNull Intent originalIntent) {
+ wrappingIntent.putExtra(EXTRA_ORIGINAL_INTENT_KEY, originalIntent);
+ }
+
+ /**
+ * Tries to extract the embedded "original" intent. Gearhead doesn't set this, so it won't always
+ * be there.
+ */
+ @NonNull
+ public static Intent extractOriginalIntent(@NonNull Intent binderIntent) {
+ Intent originalIntent = binderIntent.getParcelableExtra(EXTRA_ORIGINAL_INTENT_KEY);
+ return originalIntent != null ? originalIntent : binderIntent;
+ }
+
+ /**
+ * Removes any extras that we pass around internally as metadata, preventing them from being
+ * exposed to the client apps.
+ */
+ public static void removeInternalIntentExtras(Intent intent, List<String> extrasToRemove) {
+ for (String extra : extrasToRemove) {
+ intent.removeExtra(extra);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/InvalidatedCarHostException.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/InvalidatedCarHostException.java
new file mode 100644
index 0000000..90aeda8
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/InvalidatedCarHostException.java
@@ -0,0 +1,25 @@
+/*
+ * 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.common;
+
+/** An exception denoting that the car host was accessed after it has become invalidated */
+public class InvalidatedCarHostException extends IllegalStateException {
+ /** Constructs a {@link InvalidatedCarHostException} instance. */
+ public InvalidatedCarHostException(String message) {
+ super(message);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/LocationMediator.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/LocationMediator.java
new file mode 100644
index 0000000..931d5f1
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/LocationMediator.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.common;
+
+import android.location.Location;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarLocation;
+import androidx.car.app.model.Place;
+import java.util.List;
+
+/**
+ * A mediator for communicating {@link Place}s, and related information, from and to different
+ * components in the UI hierarchy.
+ */
+public interface LocationMediator extends AppHostService {
+ /**
+ * Listener for notifying location changes by the app.
+ *
+ * <p>We do not go through the EventManager because we need to keep track of the listeners that
+ * are registered so we know when to start and stop requesting location updates from the app.
+ */
+ interface AppLocationListener {
+ void onAppLocationChanged(Location location);
+ }
+
+ /** Returns the current set of places of interest, or an empty list if there are none. */
+ List<Place> getCurrentPlaces();
+
+ /** Set a new list of places. */
+ void setCurrentPlaces(List<Place> places);
+
+ /** Returns the point when the camera was last anchored, or {@code null} if there was none. */
+ @Nullable
+ CarLocation getCameraAnchor();
+
+ /** Set the center point of where the camera is anchored, or {@code null} if it is unknown. */
+ void setCameraAnchor(@Nullable CarLocation cameraAnchor);
+
+ /**
+ * Add a listener for getting app location updates.
+ *
+ * <p>Note that using this on {@link androidx.car.app.versioning.CarAppApiLevel} 3 or lower would
+ * have no effect.
+ */
+ void addAppLocationListener(AppLocationListener listener);
+
+ /**
+ * Removes the listener which stops it from receiving app location updates.
+ *
+ * <p>Note that using this on {@link androidx.car.app.versioning.CarAppApiLevel} 3 or lower would
+ * have no effect.
+ */
+ void removeAppLocationListener(AppLocationListener listener);
+
+ /**
+ * Sets the {@link Location} as provided by the app.
+ *
+ * <p>This will notify the {@link AppLocationListener} that have been registered.
+ */
+ void setAppLocation(Location location);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapGestureManager.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapGestureManager.java
new file mode 100644
index 0000000..fabef46
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapGestureManager.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.common;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+
+/** Gesture manager that handles gestures in map-based template presenters. */
+public class MapGestureManager {
+ /** The minimum span value for the scale event. */
+ private static final int MIN_SCALE_SPAN_DP = 10;
+
+ private final ScaleGestureDetector mScaleGestureDetector;
+ private final GestureDetector mGestureDetector;
+ private final MapOnGestureListener mGestureListener;
+
+ public MapGestureManager(TemplateContext templateContext, long touchUpdateThresholdMillis) {
+ Handler touchHandler = new Handler(Looper.getMainLooper());
+ mGestureListener = new MapOnGestureListener(templateContext, touchUpdateThresholdMillis);
+ mScaleGestureDetector =
+ new ScaleGestureDetector(
+ templateContext, mGestureListener, touchHandler, MIN_SCALE_SPAN_DP);
+ mGestureDetector = new GestureDetector(templateContext, mGestureListener, touchHandler);
+ }
+
+ /** Handles the gesture from the given motion event. */
+ public void handleGesture(MotionEvent event) {
+ mScaleGestureDetector.onTouchEvent(event);
+ if (!mScaleGestureDetector.isInProgress()) {
+ mGestureDetector.onTouchEvent(event);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapOnGestureListener.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapOnGestureListener.java
new file mode 100644
index 0000000..ef13bad
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapOnGestureListener.java
@@ -0,0 +1,293 @@
+/*
+ * 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.common;
+
+import android.graphics.Rect;
+import android.os.SystemClock;
+import android.view.GestureDetector.SimpleOnGestureListener;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import com.android.car.libraries.apphost.common.ScaleGestureDetector.OnScaleGestureListener;
+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 java.text.DecimalFormat;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Gesture listener in map-based template presenters.
+ *
+ * <p>The following events are rate-limited to reduce the delay between touch gestures and the app
+ * response:
+ *
+ * <ul>
+ * <li>{@link #onScroll(MotionEvent, MotionEvent, float, float)}
+ * <li>{@link #onScale(ScaleGestureDetector)}
+ * </ul>
+ */
+public class MapOnGestureListener extends SimpleOnGestureListener
+ implements OnScaleGestureListener {
+ /** Maximum number of debug overlay texts to display. */
+ private static final int MAX_DEBUG_OVERLAY_LINES = 3;
+
+ /** The scale factor to send to the app when the user double taps on the screen. */
+ private static final float DOUBLE_TAP_ZOOM_FACTOR = 2f;
+
+ private final DecimalFormat mDecimalFormat = new DecimalFormat("#.##");
+
+ private final Deque<String> mDebugOverlayTexts = new ArrayDeque<>();
+
+ private final TemplateContext mTemplateContext;
+
+ /** The time threshold between touch events. */
+ private final long mTouchUpdateThresholdMillis;
+
+ /** The last time that a scroll touch event happened. */
+ private long mScrollLastTouchTimeMillis;
+
+ /** The last time that a scale touch event happened. */
+ private long mScaleLastTouchTimeMillis;
+
+ /** The scroll distance in the X axis since the last distance update to the car app. */
+ private float mCumulativeDistanceX;
+
+ /** The scroll distance in the Y axis since the last distance update to the car app. */
+ private float mCumulativeDistanceY;
+
+ /**
+ * A flag that indicates that the scale gesture just ended.
+ *
+ * <p>This flag is used to work around the issue where a fling gesture is detected when a scale
+ * event ends.
+ */
+ private boolean mScaleJustEnded;
+
+ /** A flag that indicates that user is currently scrolling. */
+ private boolean mIsScrolling;
+
+ public MapOnGestureListener(TemplateContext templateContext, long touchUpdateThresholdMillis) {
+ this.mTemplateContext = templateContext;
+ this.mTouchUpdateThresholdMillis = touchUpdateThresholdMillis;
+ }
+
+ @Override
+ public boolean onDown(MotionEvent e) {
+ L.d(LogTags.TEMPLATE, "Down touch event detected");
+ // Reset the flag that indicates that a sequence of scroll events may be starting from this
+ // point.
+ mIsScrolling = false;
+
+ mCumulativeDistanceX = 0;
+ mCumulativeDistanceY = 0;
+ return super.onDown(e);
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+ long touchTimeMillis = SystemClock.uptimeMillis();
+
+ // If this is the first scroll event in a series of gestures, log a telemetry event.
+ // This avoids triggering more than one event per sequence from finger touching down to
+ // finger lifted off the screen.
+ if (!mIsScrolling) {
+ // Since this is essentially the beginning of the scroll gesture, we need to check if
+ // SurfaceCallbackHandler allows the scroll to begin (e.g. checking against whether the
+ // user is already interacting with the screen too often).
+ SurfaceCallbackHandler handler = mTemplateContext.getSurfaceCallbackHandler();
+ if (!handler.canStartNewGesture()) {
+ mCumulativeDistanceX = 0;
+ mCumulativeDistanceY = 0;
+ return true;
+ }
+
+ mIsScrolling = true;
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(TelemetryEvent.newBuilder(UiAction.PAN));
+ }
+
+ mCumulativeDistanceX += distanceX;
+ mCumulativeDistanceY += distanceY;
+
+ if (touchTimeMillis - mScrollLastTouchTimeMillis > mTouchUpdateThresholdMillis) {
+ mTemplateContext
+ .getSurfaceCallbackHandler()
+ .onScroll(mCumulativeDistanceX, mCumulativeDistanceY);
+ mScrollLastTouchTimeMillis = touchTimeMillis;
+
+ // Reset the cumulative distance.
+ mCumulativeDistanceX = 0;
+ mCumulativeDistanceY = 0;
+
+ if (mTemplateContext.getDebugOverlayHandler().isActive()) {
+ updateDebugOverlay(
+ "scroll distance [X: "
+ + mDecimalFormat.format(distanceX)
+ + ", Y: "
+ + mDecimalFormat.format(distanceY)
+ + "]");
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ // Do not send fling events when the scale event just ended. This works around the issue
+ // where a fling gesture is detected when a scale event ends.
+ if (!mScaleJustEnded) {
+ // Note that unlike the scroll, scale and double-tap events, onFling happens at the end of
+ // scroll events, so we do not check against SurfaceCallbackHandler#canStartNewGesture.
+ mTemplateContext.getSurfaceCallbackHandler().onFling(velocityX, velocityY);
+
+ if (mTemplateContext.getDebugOverlayHandler().isActive()) {
+ updateDebugOverlay(
+ "fling velocity [X: "
+ + mDecimalFormat.format(velocityX)
+ + ", Y: "
+ + mDecimalFormat.format(velocityY)
+ + "]");
+ }
+
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(TelemetryEvent.newBuilder(UiAction.FLING));
+ } else {
+ mScaleJustEnded = false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ SurfaceCallbackHandler handler = mTemplateContext.getSurfaceCallbackHandler();
+ if (!handler.canStartNewGesture()) {
+ return false;
+ }
+
+ float x = e.getX();
+ float y = e.getY();
+
+ // We cannot reliably map the touch pad position to the screen position.
+ // If the double tap happened in a touch pad, zoom into the center of the surface.
+ if (e.getSource() == InputDevice.SOURCE_TOUCHPAD) {
+ Rect visibleArea = mTemplateContext.getSurfaceInfoProvider().getVisibleArea();
+ if (visibleArea != null) {
+ x = visibleArea.centerX();
+ y = visibleArea.centerY();
+ } else {
+ // If we do not know the visible area, send negative focal point values to indicate
+ // that it is unavailable.
+ x = -1;
+ y = -1;
+ }
+ }
+
+ handler.onScale(x, y, DOUBLE_TAP_ZOOM_FACTOR);
+
+ if (mTemplateContext.getDebugOverlayHandler().isActive()) {
+ updateDebugOverlay(
+ "scale focus [X: "
+ + mDecimalFormat.format(x)
+ + ", Y: "
+ + mDecimalFormat.format(y)
+ + "], factor ["
+ + DOUBLE_TAP_ZOOM_FACTOR
+ + "]");
+ }
+
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(TelemetryEvent.newBuilder(UiAction.ZOOM));
+
+ return true;
+ }
+
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ long touchTimeMillis = SystemClock.uptimeMillis();
+ boolean shouldSendScaleEvent =
+ touchTimeMillis - mScaleLastTouchTimeMillis > mTouchUpdateThresholdMillis;
+ if (shouldSendScaleEvent) {
+ handleScale(detector);
+ mScaleLastTouchTimeMillis = touchTimeMillis;
+ }
+
+ // If we return false here, the detector will continue accumulating the scale factor until
+ // the next time we return true.
+ return shouldSendScaleEvent;
+ }
+
+ @Override
+ public boolean onScaleBegin(ScaleGestureDetector detector) {
+ // We need to check if SurfaceCallbackHandler allows the scaling gesture to begin (e.g. checking
+ // against whether the user is already interacting with the screen too often). Returning false
+ // here if needed to tell the detector to ignore the rest of the gesture.
+ SurfaceCallbackHandler handler = mTemplateContext.getSurfaceCallbackHandler();
+ return handler.canStartNewGesture();
+ }
+
+ @Override
+ public void onScaleEnd(ScaleGestureDetector detector) {
+ handleScale(detector);
+ mScaleJustEnded = true;
+
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(TelemetryEvent.newBuilder(UiAction.ZOOM));
+ }
+
+ private void handleScale(ScaleGestureDetector detector) {
+ // The focus values are only meaningful when the motion is in progress
+ if (detector.isInProgress()) {
+ float focusX = detector.getFocusX();
+ float focusY = detector.getFocusY();
+ float scaleFactor = detector.getScaleFactor();
+ mTemplateContext.getSurfaceCallbackHandler().onScale(focusX, focusY, scaleFactor);
+
+ if (mTemplateContext.getDebugOverlayHandler().isActive()) {
+ updateDebugOverlay(
+ "scale focus [X: "
+ + mDecimalFormat.format(focusX)
+ + ", Y: "
+ + mDecimalFormat.format(focusY)
+ + "], factor ["
+ + mDecimalFormat.format(scaleFactor)
+ + "]");
+ }
+ }
+ }
+
+ private void updateDebugOverlay(String debugText) {
+ if (mDebugOverlayTexts.size() >= MAX_DEBUG_OVERLAY_LINES) {
+ mDebugOverlayTexts.removeFirst();
+ }
+ mDebugOverlayTexts.addLast(debugText);
+
+ StringBuilder sb = new StringBuilder();
+ for (String text : mDebugOverlayTexts) {
+ sb.append(text);
+ sb.append("\n");
+ }
+
+ // Remove the last newline.
+ sb.setLength(sb.length() - 1);
+ mTemplateContext.getDebugOverlayHandler().updateDebugOverlayEntry("Gesture", sb.toString());
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapViewContainer.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapViewContainer.java
new file mode 100644
index 0000000..17ef88e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/MapViewContainer.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.common;
+
+import androidx.annotation.NonNull;
+import androidx.car.app.model.Place;
+import androidx.lifecycle.LifecycleRegistry;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Represents a layout that wraps a map view. */
+public interface MapViewContainer {
+ /**
+ * Returns the {@link LifecycleRegistry} instance that can be used by a parent of the container to
+ * drive the lifecycle events of the map view wrapped by it.
+ */
+ @NonNull
+ LifecycleRegistry getLifecycleRegistry();
+
+ /**
+ * Sets whether current location is enabled.
+ *
+ * @param enable true if the map should show the current location
+ */
+ void setCurrentLocationEnabled(boolean enable);
+
+ /** Sets the map anchor. The camera will be adjusted to include the anchor marker if necessary. */
+ void setAnchor(@Nullable Place anchor);
+
+ /**
+ * Sets the places to display in the map. The camera will be moved to the region that contains all
+ * the places.
+ */
+ void setPlaces(List<Place> places);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/NamedAppServiceCall.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/NamedAppServiceCall.java
new file mode 100644
index 0000000..dbce0f8
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/NamedAppServiceCall.java
@@ -0,0 +1,56 @@
+/*
+ * 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.common;
+
+import android.os.RemoteException;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+
+/**
+ * A {@link AppServiceCall} decorated with a name, useful for logging.
+ *
+ * @param <ServiceT> the type of service to make the call for.
+ */
+public class NamedAppServiceCall<ServiceT> implements AppServiceCall<ServiceT> {
+ private final AppServiceCall<ServiceT> mCall;
+ private final CarAppApi mCarAppApi;
+
+ /** Creates an instance of a {@link NamedAppServiceCall} for the given API. */
+ public static <ServiceT> NamedAppServiceCall<ServiceT> create(
+ CarAppApi carAppApi, AppServiceCall<ServiceT> call) {
+ return new NamedAppServiceCall<>(carAppApi, call);
+ }
+
+ /** Returns the API this call is made for. */
+ public CarAppApi getCarAppApi() {
+ return mCarAppApi;
+ }
+
+ @Override
+ public void dispatch(ServiceT appService, ANRHandler.ANRToken anrToken) throws RemoteException {
+ mCall.dispatch(appService, anrToken);
+ }
+
+ @Override
+ public String toString() {
+ return "[" + mCarAppApi.name() + "]";
+ }
+
+ private NamedAppServiceCall(CarAppApi carAppApi, AppServiceCall<ServiceT> call) {
+ mCall = call;
+ mCarAppApi = carAppApi;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OnDoneCallbackStub.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OnDoneCallbackStub.java
new file mode 100644
index 0000000..a7084d0
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OnDoneCallbackStub.java
@@ -0,0 +1,125 @@
+/*
+ * 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.common;
+
+import static com.android.car.libraries.apphost.logging.TelemetryHandler.getErrorType;
+
+import android.content.ComponentName;
+import androidx.annotation.CallSuper;
+import androidx.annotation.Nullable;
+import androidx.car.app.FailureResponse;
+import androidx.car.app.IOnDoneCallback;
+import androidx.car.app.OnDoneCallback;
+import androidx.car.app.serialization.Bundleable;
+import androidx.car.app.serialization.BundlerException;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+import com.android.car.libraries.apphost.logging.TelemetryHandler;
+
+/**
+ * Default {@link IOnDoneCallback} that will log telemetry for API success and failure, handle ANR,
+ * as well as release the blocking thread, by setting a {@code null} on the blocking response for
+ * any api that blocks for this callback.
+ */
+public class OnDoneCallbackStub extends IOnDoneCallback.Stub implements OnDoneCallback {
+ private final ErrorHandler mErrorHandler;
+ private final ComponentName mAppName;
+ private final ANRHandler.ANRToken mANRToken;
+ private final TelemetryHandler mTelemetryHandler;
+ private final AppBindingStateProvider mAppBindingStateProvider;
+
+ /**
+ * Constructs an {@link OnDoneCallbackStub} that will release the given {@link
+ * ANRHandler.ANRToken} when {@link #onSuccess} or {@link #onFailure} is called.
+ */
+ public OnDoneCallbackStub(TemplateContext templateContext, ANRHandler.ANRToken anrToken) {
+ this(
+ templateContext.getErrorHandler(),
+ templateContext.getCarAppPackageInfo().getComponentName(),
+ anrToken,
+ templateContext.getTelemetryHandler(),
+ templateContext.getAppBindingStateProvider());
+ }
+
+ /**
+ * Constructs an {@link OnDoneCallbackStub} that will release the given {@link
+ * ANRHandler.ANRToken} when {@link #onSuccess} or {@link #onFailure} is called.
+ */
+ public OnDoneCallbackStub(
+ ErrorHandler errorHandler,
+ ComponentName appName,
+ ANRHandler.ANRToken anrToken,
+ TelemetryHandler telemetryHandler,
+ AppBindingStateProvider appBindingStateProvider) {
+ mErrorHandler = errorHandler;
+ mAppName = appName;
+ mANRToken = anrToken;
+ mTelemetryHandler = telemetryHandler;
+ mAppBindingStateProvider = appBindingStateProvider;
+ }
+
+ @CallSuper
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ mANRToken.dismiss();
+ mTelemetryHandler.logCarAppApiSuccessTelemetry(mAppName, mANRToken.getCarAppApi());
+ }
+
+ @CallSuper
+ @Override
+ public void onFailure(Bundleable failureResponse) {
+ mANRToken.dismiss();
+ ThreadUtils.runOnMain(
+ () -> {
+ FailureResponse failure;
+ try {
+ failure = (FailureResponse) failureResponse.get();
+
+ CarAppError.Builder errorBuilder =
+ CarAppError.builder(mAppName).setDebugMessage(failure.getStackTrace());
+ if (shouldLogTelemetryForError(
+ mANRToken.getCarAppApi(), mAppBindingStateProvider.isAppBound())) {
+ mTelemetryHandler.logCarAppApiFailureTelemetry(
+ mAppName, mANRToken.getCarAppApi(), getErrorType(failure));
+ } else {
+ errorBuilder.setLogVerbose(true);
+ }
+
+ mErrorHandler.showError(errorBuilder.build());
+ } catch (BundlerException e) {
+ mErrorHandler.showError(CarAppError.builder(mAppName).setCause(e).build());
+
+ // If we fail to unbundle the response, log telemetry as a failed IPC due to bundling.
+ mTelemetryHandler.logCarAppApiFailureTelemetry(
+ mAppName, mANRToken.getCarAppApi(), getErrorType(new FailureResponse(e)));
+ }
+ });
+ }
+
+ private static boolean shouldLogTelemetryForError(CarAppApi api, boolean isAppBound) {
+ boolean isApiPreBinding;
+ switch (api) {
+ case GET_APP_VERSION:
+ case ON_HANDSHAKE_COMPLETED:
+ case ON_APP_CREATE:
+ isApiPreBinding = true;
+ break;
+ default:
+ isApiPreBinding = false;
+ }
+ return isAppBound || isApiPreBinding;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OneWayIPC.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OneWayIPC.java
new file mode 100644
index 0000000..2e9aeea
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/OneWayIPC.java
@@ -0,0 +1,34 @@
+/*
+ * 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.common;
+
+import android.os.RemoteException;
+import androidx.car.app.serialization.BundlerException;
+import com.android.car.libraries.apphost.common.ANRHandler.ANRToken;
+
+/**
+ * A request to send over the wire to the app.
+ *
+ * <p>The method interface of the client should be marked {@code oneway}.
+ *
+ * <p>You should not call {@link #send} yourself, but rather use the {@link AppDispatcher} to send
+ * this request. This allows for a single location to handle exceptions and performing IPC.
+ */
+public interface OneWayIPC {
+ /** Sends an IPC to the app, using the given {@link ANRToken}. */
+ void send(ANRToken anrToken) throws BundlerException, RemoteException;
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/RoutingInfoState.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/RoutingInfoState.java
new file mode 100644
index 0000000..72b5adc
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/RoutingInfoState.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.common;
+
+/**
+ * Manages the state of routing information in template apps.
+ *
+ * <p>This class tracks the state of routing information across multiple template apps.
+ */
+public interface RoutingInfoState {
+ /** Sets whether routing information is visible on the car screen. */
+ void setIsRoutingInfoVisible(boolean isVisible);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ScaleGestureDetector.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ScaleGestureDetector.java
new file mode 100644
index 0000000..4104d33
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ScaleGestureDetector.java
@@ -0,0 +1,555 @@
+/*
+ * 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.common;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.Handler;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+
+/**
+ * This class is forked from {@link android.view.ScaleGestureDetector} in order to modify {@link
+ * #mMinSpan} attribute.
+ *
+ * <p>{@link #mMinSpan} caused the detector to ignore pinch-zoom events when the distance between
+ * the fingers was too small. See b/193927730 for more details.
+ */
+public class ScaleGestureDetector {
+ private static final String TAG = "ScaleGestureDetector";
+
+ /**
+ * The listener for receiving notifications when gestures occur. If you want to listen for all the
+ * different gestures then implement this interface. If you only want to listen for a subset it
+ * might be easier to extend {@link SimpleOnScaleGestureListener}.
+ *
+ * <p>An application will receive events in the following order:
+ *
+ * <ul>
+ * <li>One {@link OnScaleGestureListener#onScaleBegin(ScaleGestureDetector)}
+ * <li>Zero or more {@link OnScaleGestureListener#onScale(ScaleGestureDetector)}
+ * <li>One {@link OnScaleGestureListener#onScaleEnd(ScaleGestureDetector)}
+ * </ul>
+ */
+ public interface OnScaleGestureListener {
+ /**
+ * Responds to scaling events for a gesture in progress. Reported by pointer motion.
+ *
+ * @param detector The detector reporting the event - use this to retrieve extended info about
+ * event state.
+ * @return Whether or not the detector should consider this event as handled. If an event was
+ * not handled, the detector will continue to accumulate movement until an event is handled.
+ * This can be useful if an application, for example, only wants to update scaling factors
+ * if the change is greater than 0.01.
+ */
+ boolean onScale(ScaleGestureDetector detector);
+
+ /**
+ * Responds to the beginning of a scaling gesture. Reported by new pointers going down.
+ *
+ * @param detector The detector reporting the event - use this to retrieve extended info about
+ * event state.
+ * @return Whether or not the detector should continue recognizing this gesture. For example, if
+ * a gesture is beginning with a focal point outside of a region where it makes sense,
+ * onScaleBegin() may return false to ignore the rest of the gesture.
+ */
+ boolean onScaleBegin(ScaleGestureDetector detector);
+
+ /**
+ * Responds to the end of a scale gesture. Reported by existing pointers going up.
+ *
+ * <p>Once a scale has ended, {@link ScaleGestureDetector#getFocusX()} and {@link
+ * ScaleGestureDetector#getFocusY()} will return focal point of the pointers remaining on the
+ * screen.
+ *
+ * @param detector The detector reporting the event - use this to retrieve extended info about
+ * event state.
+ */
+ void onScaleEnd(ScaleGestureDetector detector);
+ }
+
+ /**
+ * A convenience class to extend when you only want to listen for a subset of scaling-related
+ * events. This implements all methods in {@link OnScaleGestureListener} but does nothing. {@link
+ * OnScaleGestureListener#onScale(ScaleGestureDetector)} returns {@code false} so that a subclass
+ * can retrieve the accumulated scale factor in an overridden onScaleEnd. {@link
+ * OnScaleGestureListener#onScaleBegin(ScaleGestureDetector)} returns {@code true}.
+ */
+ public static class SimpleOnScaleGestureListener implements OnScaleGestureListener {
+
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ return false;
+ }
+
+ @Override
+ public boolean onScaleBegin(ScaleGestureDetector detector) {
+ return true;
+ }
+
+ @Override
+ public void onScaleEnd(ScaleGestureDetector detector) {
+ // Intentionally empty
+ }
+ }
+
+ private final Context mContext;
+ private final OnScaleGestureListener mListener;
+
+ private float mFocusX;
+ private float mFocusY;
+
+ private boolean mQuickScaleEnabled;
+ private boolean mStylusScaleEnabled;
+
+ private float mCurrSpan;
+ private float mPrevSpan;
+ private float mInitialSpan;
+ private float mCurrSpanX;
+ private float mCurrSpanY;
+ private float mPrevSpanX;
+ private float mPrevSpanY;
+ private long mCurrTime;
+ private long mPrevTime;
+ private boolean mInProgress;
+ private final int mSpanSlop;
+ private final int mMinSpan;
+
+ private final Handler mHandler;
+
+ private float mAnchoredScaleStartX;
+ private float mAnchoredScaleStartY;
+ private int mAnchoredScaleMode = ANCHORED_SCALE_MODE_NONE;
+
+ private static final float SCALE_FACTOR = .5f;
+ private static final int ANCHORED_SCALE_MODE_NONE = 0;
+ private static final int ANCHORED_SCALE_MODE_DOUBLE_TAP = 1;
+ private static final int ANCHORED_SCALE_MODE_STYLUS = 2;
+
+ private GestureDetector mGestureDetector;
+
+ private boolean mEventBeforeOrAboveStartingGestureEvent;
+
+ /**
+ * Creates a ScaleGestureDetector with the supplied listener. You may only use this constructor
+ * from a {@link android.os.Looper Looper} thread.
+ *
+ * @param context the application's context
+ * @param listener the listener invoked for all the callbacks, this must not be null.
+ * @throws NullPointerException if {@code listener} is null.
+ */
+ @SuppressWarnings("nullness:argument")
+ public ScaleGestureDetector(Context context, OnScaleGestureListener listener) {
+ this(context, listener, null, -1);
+ }
+
+ /**
+ * Creates a ScaleGestureDetector with the supplied listener.
+ *
+ * @see android.os.Handler#Handler()
+ * @param context the application's context
+ * @param listener the listener invoked for all the callbacks, this must not be null.
+ * @param handler the handler to use for running deferred listener events.
+ * @param minSpan the minimum span for the gesture to be recognized as a scale event.
+ * @throws NullPointerException if {@code listener} is null.
+ */
+ @SuppressWarnings("nullness:method.invocation")
+ public ScaleGestureDetector(
+ Context context, OnScaleGestureListener listener, Handler handler, int minSpan) {
+ mContext = context;
+ mListener = listener;
+ final ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
+ mSpanSlop = viewConfiguration.getScaledTouchSlop() * 2;
+ mMinSpan = Math.max(minSpan, 0);
+ mHandler = handler;
+ // Quick scale is enabled by default after JB_MR2
+ final int targetSdkVersion = context.getApplicationInfo().targetSdkVersion;
+ if (targetSdkVersion > Build.VERSION_CODES.JELLY_BEAN_MR2) {
+ setQuickScaleEnabled(true);
+ }
+ // Stylus scale is enabled by default after LOLLIPOP_MR1
+ if (targetSdkVersion > Build.VERSION_CODES.LOLLIPOP_MR1) {
+ setStylusScaleEnabled(true);
+ }
+ }
+
+ /**
+ * Accepts MotionEvents and dispatches events to a {@link OnScaleGestureListener} when
+ * appropriate.
+ *
+ * <p>Applications should pass a complete and consistent event stream to this method. A complete
+ * and consistent event stream involves all MotionEvents from the initial ACTION_DOWN to the final
+ * ACTION_UP or ACTION_CANCEL.
+ *
+ * @param event The event to process
+ * @return true if the event was processed and the detector wants to receive the rest of the
+ * MotionEvents in this event stream.
+ */
+ public boolean onTouchEvent(MotionEvent event) {
+ mCurrTime = event.getEventTime();
+
+ final int action = event.getActionMasked();
+
+ // Forward the event to check for double tap gesture
+ if (mQuickScaleEnabled) {
+ mGestureDetector.onTouchEvent(event);
+ }
+
+ final int count = event.getPointerCount();
+ final boolean isStylusButtonDown =
+ (event.getButtonState() & MotionEvent.BUTTON_STYLUS_PRIMARY) != 0;
+
+ final boolean anchoredScaleCancelled =
+ mAnchoredScaleMode == ANCHORED_SCALE_MODE_STYLUS && !isStylusButtonDown;
+ final boolean streamComplete =
+ action == MotionEvent.ACTION_UP
+ || action == MotionEvent.ACTION_CANCEL
+ || anchoredScaleCancelled;
+
+ if (action == MotionEvent.ACTION_DOWN || streamComplete) {
+ // Reset any scale in progress with the listener.
+ // If it's an ACTION_DOWN we're beginning a new event stream.
+ // This means the app probably didn't give us all the events. Shame on it.
+ if (mInProgress) {
+ mListener.onScaleEnd(this);
+ mInProgress = false;
+ mInitialSpan = 0;
+ mAnchoredScaleMode = ANCHORED_SCALE_MODE_NONE;
+ } else if (inAnchoredScaleMode() && streamComplete) {
+ mInProgress = false;
+ mInitialSpan = 0;
+ mAnchoredScaleMode = ANCHORED_SCALE_MODE_NONE;
+ }
+
+ if (streamComplete) {
+ return true;
+ }
+ }
+
+ if (!mInProgress
+ && mStylusScaleEnabled
+ && !inAnchoredScaleMode()
+ && !streamComplete
+ && isStylusButtonDown) {
+ // Start of a button scale gesture
+ mAnchoredScaleStartX = event.getX();
+ mAnchoredScaleStartY = event.getY();
+ mAnchoredScaleMode = ANCHORED_SCALE_MODE_STYLUS;
+ mInitialSpan = 0;
+ }
+
+ final boolean configChanged =
+ action == MotionEvent.ACTION_DOWN
+ || action == MotionEvent.ACTION_POINTER_UP
+ || action == MotionEvent.ACTION_POINTER_DOWN
+ || anchoredScaleCancelled;
+
+ final boolean pointerUp = action == MotionEvent.ACTION_POINTER_UP;
+ final int skipIndex = pointerUp ? event.getActionIndex() : -1;
+
+ // Determine focal point
+ float sumX = 0;
+ float sumY = 0;
+ final int div = pointerUp ? count - 1 : count;
+ final float focusX;
+ final float focusY;
+ if (inAnchoredScaleMode()) {
+ // In anchored scale mode, the focal pt is always where the double tap
+ // or button down gesture started
+ focusX = mAnchoredScaleStartX;
+ focusY = mAnchoredScaleStartY;
+ if (event.getY() < focusY) {
+ mEventBeforeOrAboveStartingGestureEvent = true;
+ } else {
+ mEventBeforeOrAboveStartingGestureEvent = false;
+ }
+ } else {
+ for (int i = 0; i < count; i++) {
+ if (skipIndex == i) {
+ continue;
+ }
+ sumX += event.getX(i);
+ sumY += event.getY(i);
+ }
+
+ focusX = sumX / div;
+ focusY = sumY / div;
+ }
+
+ // Determine average deviation from focal point
+ float devSumX = 0;
+ float devSumY = 0;
+ for (int i = 0; i < count; i++) {
+ if (skipIndex == i) {
+ continue;
+ }
+
+ // Convert the resulting diameter into a radius.
+ devSumX += Math.abs(event.getX(i) - focusX);
+ devSumY += Math.abs(event.getY(i) - focusY);
+ }
+ final float devX = devSumX / div;
+ final float devY = devSumY / div;
+
+ // Span is the average distance between touch points through the focal point;
+ // i.e. the diameter of the circle with a radius of the average deviation from
+ // the focal point.
+ final float spanX = devX * 2;
+ final float spanY = devY * 2;
+ final float span;
+ if (inAnchoredScaleMode()) {
+ span = spanY;
+ } else {
+ span = (float) Math.hypot(spanX, spanY);
+ }
+
+ // Dispatch begin/end events as needed.
+ // If the configuration changes, notify the app to reset its current state by beginning
+ // a fresh scale event stream.
+ final boolean wasInProgress = mInProgress;
+ mFocusX = focusX;
+ mFocusY = focusY;
+ if (!inAnchoredScaleMode() && mInProgress && (span < mMinSpan || configChanged)) {
+ mListener.onScaleEnd(this);
+ mInProgress = false;
+ mInitialSpan = span;
+ }
+ if (configChanged) {
+ mPrevSpanX = mCurrSpanX = spanX;
+ mPrevSpanY = mCurrSpanY = spanY;
+ mInitialSpan = mPrevSpan = mCurrSpan = span;
+ }
+
+ final int minSpan = inAnchoredScaleMode() ? mSpanSlop : mMinSpan;
+ if (!mInProgress
+ && span >= minSpan
+ && (wasInProgress || Math.abs(span - mInitialSpan) > mSpanSlop)) {
+ mPrevSpanX = mCurrSpanX = spanX;
+ mPrevSpanY = mCurrSpanY = spanY;
+ mPrevSpan = mCurrSpan = span;
+ mPrevTime = mCurrTime;
+ mInProgress = mListener.onScaleBegin(this);
+ }
+
+ // Handle motion; focal point and span/scale factor are changing.
+ if (action == MotionEvent.ACTION_MOVE) {
+ mCurrSpanX = spanX;
+ mCurrSpanY = spanY;
+ mCurrSpan = span;
+
+ boolean updatePrev = true;
+
+ if (mInProgress) {
+ updatePrev = mListener.onScale(this);
+ }
+
+ if (updatePrev) {
+ mPrevSpanX = mCurrSpanX;
+ mPrevSpanY = mCurrSpanY;
+ mPrevSpan = mCurrSpan;
+ mPrevTime = mCurrTime;
+ }
+ }
+
+ return true;
+ }
+
+ private boolean inAnchoredScaleMode() {
+ return mAnchoredScaleMode != ANCHORED_SCALE_MODE_NONE;
+ }
+
+ /**
+ * Set whether the associated {@link OnScaleGestureListener} should receive onScale callbacks when
+ * the user performs a doubleTap followed by a swipe. Note that this is enabled by default if the
+ * app targets API 19 and newer.
+ *
+ * @param scales true to enable quick scaling, false to disable
+ */
+ public void setQuickScaleEnabled(boolean scales) {
+ mQuickScaleEnabled = scales;
+ if (mQuickScaleEnabled && mGestureDetector == null) {
+ GestureDetector.SimpleOnGestureListener gestureListener =
+ new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ // Double tap: start watching for a swipe
+ mAnchoredScaleStartX = e.getX();
+ mAnchoredScaleStartY = e.getY();
+ mAnchoredScaleMode = ANCHORED_SCALE_MODE_DOUBLE_TAP;
+ return true;
+ }
+ };
+ mGestureDetector = new GestureDetector(mContext, gestureListener, mHandler);
+ }
+ }
+
+ /**
+ * Return whether the quick scale gesture, in which the user performs a double tap followed by a
+ * swipe, should perform scaling. {@see #setQuickScaleEnabled(boolean)}.
+ */
+ public boolean isQuickScaleEnabled() {
+ return mQuickScaleEnabled;
+ }
+
+ /**
+ * Sets whether the associates {@link OnScaleGestureListener} should receive onScale callbacks
+ * when the user uses a stylus and presses the button. Note that this is enabled by default if the
+ * app targets API 23 and newer.
+ *
+ * @param scales true to enable stylus scaling, false to disable.
+ */
+ public void setStylusScaleEnabled(boolean scales) {
+ mStylusScaleEnabled = scales;
+ }
+
+ /**
+ * Return whether the stylus scale gesture, in which the user uses a stylus and presses the
+ * button, should perform scaling. {@see #setStylusScaleEnabled(boolean)}
+ */
+ public boolean isStylusScaleEnabled() {
+ return mStylusScaleEnabled;
+ }
+
+ /** Returns {@code true} if a scale gesture is in progress. */
+ public boolean isInProgress() {
+ return mInProgress;
+ }
+
+ /**
+ * Get the X coordinate of the current gesture's focal point. If a gesture is in progress, the
+ * focal point is between each of the pointers forming the gesture.
+ *
+ * <p>If {@link #isInProgress()} would return false, the result of this function is undefined.
+ *
+ * @return X coordinate of the focal point in pixels.
+ */
+ public float getFocusX() {
+ return mFocusX;
+ }
+
+ /**
+ * Get the Y coordinate of the current gesture's focal point. If a gesture is in progress, the
+ * focal point is between each of the pointers forming the gesture.
+ *
+ * <p>If {@link #isInProgress()} would return false, the result of this function is undefined.
+ *
+ * @return Y coordinate of the focal point in pixels.
+ */
+ public float getFocusY() {
+ return mFocusY;
+ }
+
+ /**
+ * Return the average distance between each of the pointers forming the gesture in progress
+ * through the focal point.
+ *
+ * @return Distance between pointers in pixels.
+ */
+ public float getCurrentSpan() {
+ return mCurrSpan;
+ }
+
+ /**
+ * Return the average X distance between each of the pointers forming the gesture in progress
+ * through the focal point.
+ *
+ * @return Distance between pointers in pixels.
+ */
+ public float getCurrentSpanX() {
+ return mCurrSpanX;
+ }
+
+ /**
+ * Return the average Y distance between each of the pointers forming the gesture in progress
+ * through the focal point.
+ *
+ * @return Distance between pointers in pixels.
+ */
+ public float getCurrentSpanY() {
+ return mCurrSpanY;
+ }
+
+ /**
+ * Return the previous average distance between each of the pointers forming the gesture in
+ * progress through the focal point.
+ *
+ * @return Previous distance between pointers in pixels.
+ */
+ public float getPreviousSpan() {
+ return mPrevSpan;
+ }
+
+ /**
+ * Return the previous average X distance between each of the pointers forming the gesture in
+ * progress through the focal point.
+ *
+ * @return Previous distance between pointers in pixels.
+ */
+ public float getPreviousSpanX() {
+ return mPrevSpanX;
+ }
+
+ /**
+ * Return the previous average Y distance between each of the pointers forming the gesture in
+ * progress through the focal point.
+ *
+ * @return Previous distance between pointers in pixels.
+ */
+ public float getPreviousSpanY() {
+ return mPrevSpanY;
+ }
+
+ /**
+ * Return the scaling factor from the previous scale event to the current event. This value is
+ * defined as ({@link #getCurrentSpan()} / {@link #getPreviousSpan()}).
+ *
+ * @return The current scaling factor.
+ */
+ public float getScaleFactor() {
+ if (inAnchoredScaleMode()) {
+ // Drag is moving up; the further away from the gesture
+ // start, the smaller the span should be, the closer,
+ // the larger the span, and therefore the larger the scale
+ final boolean scaleUp =
+ mEventBeforeOrAboveStartingGestureEvent
+ ? (mCurrSpan < mPrevSpan)
+ : (mCurrSpan > mPrevSpan);
+ final float spanDiff = (Math.abs(1 - (mCurrSpan / mPrevSpan)) * SCALE_FACTOR);
+ return mPrevSpan <= mSpanSlop ? 1 : scaleUp ? (1 + spanDiff) : (1 - spanDiff);
+ }
+ return mPrevSpan > 0 ? mCurrSpan / mPrevSpan : 1;
+ }
+
+ /**
+ * Return the time difference in milliseconds between the previous accepted scaling event and the
+ * current scaling event.
+ *
+ * @return Time difference since the last scaling event in milliseconds.
+ */
+ public long getTimeDelta() {
+ return mCurrTime - mPrevTime;
+ }
+
+ /**
+ * Return the event time of the current event being processed.
+ *
+ * @return Current event time in milliseconds.
+ */
+ public long getEventTime() {
+ return mCurrTime;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StatusBarManager.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StatusBarManager.java
new file mode 100644
index 0000000..2f26643
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StatusBarManager.java
@@ -0,0 +1,46 @@
+/*
+ * 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.common;
+
+import android.view.View;
+
+/**
+ * A manager that allows presenters to control some attributes of the status bar, such as the color
+ * of the text and background.
+ */
+public interface StatusBarManager {
+ /** The type of status bar to display. */
+ enum StatusBarState {
+ /**
+ * The status bar is designed to be rendered over an app drawn surface such as a map, where it
+ * will have a background protection to ensure the user can read the status bar information.
+ */
+ OVER_SURFACE,
+
+ /**
+ * The status bar is designed to be rendered over a dark background (e.g. white text with
+ * transparent background).
+ */
+ LIGHT,
+
+ /** The status bar is designed the status bar */
+ GONE
+ }
+
+ /** Updates the {@link StatusBarState}. */
+ void setStatusBarState(StatusBarState statusBarState, View rootView);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StringUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StringUtils.java
new file mode 100644
index 0000000..b1ed7db
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/StringUtils.java
@@ -0,0 +1,72 @@
+/*
+ * 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.common;
+
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+/** Assorted string manipulation utilities. */
+public class StringUtils {
+ /** Milliseconds per unit of time LUT. Needs to be in sync with {@link #UNIT_SUFFIXES}. */
+ private static final long[] MILLIS_PER_UNIT =
+ new long[] {
+ DAYS.toMillis(1),
+ HOURS.toMillis(1),
+ MINUTES.toMillis(1),
+ SECONDS.toMillis(1),
+ 1 // 1 millisecond in milliseconds
+ };
+
+ private static final String[] UNIT_SUFFIXES = new String[] {"d", "h", "m", "s", "ms"};
+
+ /**
+ * Returns a compact string representation of a duration.
+ *
+ * <p>The format is {@code "xd:xh:xm:xs:xms"}, where {@code "x"} is an unpadded numeric value. If
+ * {@code "x"} is 0, it is altogether omitted.
+ *
+ * <p>For example, {@code "1d:25m:123ms"} denotes 1 day, 25 minutes, and 123 milliseconds.
+ *
+ * <p>Negative durations are returned as {@code "-"}
+ */
+ public static String formatDuration(long durationMillis) {
+ StringBuilder builder = new StringBuilder();
+ if (durationMillis < 0) {
+ return "-";
+ } else if (durationMillis == 0) {
+ return "0ms";
+ }
+ boolean first = true;
+ for (int i = 0; i < MILLIS_PER_UNIT.length; ++i) {
+ long value =
+ (i > 0 ? (durationMillis % MILLIS_PER_UNIT[i - 1]) : durationMillis) / MILLIS_PER_UNIT[i];
+ if (value > 0) {
+ if (first) {
+ first = false;
+ } else {
+ builder.append(":");
+ }
+ builder.append(value).append(UNIT_SUFFIXES[i]);
+ }
+ }
+ return builder.toString();
+ }
+
+ private StringUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceCallbackHandler.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceCallbackHandler.java
new file mode 100644
index 0000000..5ff1e98
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceCallbackHandler.java
@@ -0,0 +1,44 @@
+/*
+ * 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.common;
+
+/** Interface for handling surface callbacks such as pan and zoom. */
+public interface SurfaceCallbackHandler {
+
+ /** Returns whether a new gesture can begin. */
+ default boolean canStartNewGesture() {
+ return true;
+ }
+
+ /**
+ * Forwards a scroll gesture event to the car app's {@link
+ * androidx.car.app.ISurfaceCallback#onScroll(float, float)}.
+ */
+ void onScroll(float distanceX, float distanceY);
+
+ /**
+ * Forwards a fling gesture event to the car app's {@link
+ * androidx.car.app.ISurfaceCallback#onFling(float, float)}.
+ */
+ void onFling(float velocityX, float velocityY);
+
+ /**
+ * Forwards a scale gesture event to the car app's {@link
+ * androidx.car.app.ISurfaceCallback#onScale(float, float, float)}.
+ */
+ void onScale(float focusX, float focusY, float scaleFactor);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceInfoProvider.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceInfoProvider.java
new file mode 100644
index 0000000..35f9dc3
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SurfaceInfoProvider.java
@@ -0,0 +1,53 @@
+/*
+ * 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.common;
+
+import android.graphics.Rect;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A class for storing and retrieving the properties such as the visible area and stable center of a
+ * surface.
+ */
+public interface SurfaceInfoProvider {
+ /**
+ * Returns the {@link Rect} that specifies the region in the view where the templated-content
+ * (e.g. the card container, FAB) currently extends to. Returns {@code null} if the value is not
+ * set.
+ */
+ @Nullable Rect getVisibleArea();
+
+ /**
+ * Sets the safe area and if needed updates the stable center.
+ *
+ * <p>Subscribe to the event {@link EventManager.EventType#SURFACE_VISIBLE_AREA} to be notify when
+ * the safe area has been updated.
+ */
+ void setVisibleArea(Rect safeArea);
+
+ /**
+ * Returns the {@link Rect} that specifies the region of the stable visible area where the
+ * templated content (e.g. card container, action strip) could possibly extend to. It is stable in
+ * that the area is the guaranteed visible no matter any dynamic changes to the view. It is
+ * possible for stable area to increase or decrease due to changes in the template content or a
+ * template change.
+ */
+ @Nullable Rect getStableArea();
+
+ /** Indicates that the stable area should be recalculated the next time the safe area is set. */
+ void invalidateStableArea();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SystemClockWrapper.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SystemClockWrapper.java
new file mode 100644
index 0000000..9217b4e
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/SystemClockWrapper.java
@@ -0,0 +1,35 @@
+/*
+ * 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.common;
+
+import android.os.SystemClock;
+
+/**
+ * Wrapper of SystemClock
+ *
+ * <p>Real instances should just delegate the calls to the static methods, while test instances
+ * return values set manually. See {@link android.os.SystemClock}.
+ */
+public final class SystemClockWrapper {
+ /**
+ * Returns milliseconds since boot, including time spent in sleep.
+ *
+ * @return elapsed milliseconds since boot
+ */
+ public long elapsedRealtime() {
+ return SystemClock.elapsedRealtime();
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/TemplateContext.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/TemplateContext.java
new file mode 100644
index 0000000..2015581
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/TemplateContext.java
@@ -0,0 +1,332 @@
+/*
+ * 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.common;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.res.Configuration;
+import androidx.annotation.Nullable;
+import com.android.car.libraries.apphost.distraction.constraints.ConstraintsProvider;
+import com.android.car.libraries.apphost.input.InputConfig;
+import com.android.car.libraries.apphost.input.InputManager;
+import com.android.car.libraries.apphost.logging.TelemetryHandler;
+import java.io.PrintWriter;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Context for various template components to retrieve important bits of information for presenting
+ * content.
+ */
+public abstract class TemplateContext extends ContextWrapper {
+
+ private final Map<Class<? extends AppHostService>, AppHostService> mAppHostServices =
+ new HashMap<>();
+
+ /**
+ * Constructs an instance of a {@link TemplateContext} wrapping the given {@link Context} object.
+ */
+ public TemplateContext(Context base) {
+ super(base);
+ }
+
+ /**
+ * Creates a {@link TemplateContext} that replaces the inner {@link Context} for the given {@link
+ * TemplateContext}.
+ *
+ * <p>This is used for using an uiContext for view elements, since they may have a theme applied
+ * on them.
+ *
+ * @param other the {@link TemplateContext} to wrap for all the getters
+ * @param uiContext the {@link Context} that this instance will wrap
+ */
+ public static TemplateContext from(TemplateContext other, Context uiContext) {
+ return new TemplateContext(uiContext) {
+ @Override
+ public CarAppPackageInfo getCarAppPackageInfo() {
+ return other.getCarAppPackageInfo();
+ }
+
+ @Override
+ public InputManager getInputManager() {
+ return other.getInputManager();
+ }
+
+ @Override
+ public ErrorHandler getErrorHandler() {
+ return other.getErrorHandler();
+ }
+
+ @Override
+ public ANRHandler getAnrHandler() {
+ return other.getAnrHandler();
+ }
+
+ @Override
+ public BackPressedHandler getBackPressedHandler() {
+ return other.getBackPressedHandler();
+ }
+
+ @Override
+ public SurfaceCallbackHandler getSurfaceCallbackHandler() {
+ return other.getSurfaceCallbackHandler();
+ }
+
+ @Override
+ public InputConfig getInputConfig() {
+ return other.getInputConfig();
+ }
+
+ @Override
+ public StatusBarManager getStatusBarManager() {
+ return other.getStatusBarManager();
+ }
+
+ @Override
+ public SurfaceInfoProvider getSurfaceInfoProvider() {
+ return other.getSurfaceInfoProvider();
+ }
+
+ @Override
+ public EventManager getEventManager() {
+ return other.getEventManager();
+ }
+
+ @Override
+ public AppDispatcher getAppDispatcher() {
+ return other.getAppDispatcher();
+ }
+
+ @Override
+ public SystemClockWrapper getSystemClockWrapper() {
+ return other.getSystemClockWrapper();
+ }
+
+ @Override
+ public ToastController getToastController() {
+ return other.getToastController();
+ }
+
+ @Override
+ @Nullable
+ public Context getAppConfigurationContext() {
+ return other.getAppConfigurationContext();
+ }
+
+ @Override
+ public CarAppManager getCarAppManager() {
+ return other.getCarAppManager();
+ }
+
+ @Override
+ public void updateConfiguration(Configuration configuration) {
+ other.updateConfiguration(configuration);
+ }
+
+ @Override
+ public TelemetryHandler getTelemetryHandler() {
+ return other.getTelemetryHandler();
+ }
+
+ @Override
+ public DebugOverlayHandler getDebugOverlayHandler() {
+ return other.getDebugOverlayHandler();
+ }
+
+ @Override
+ public RoutingInfoState getRoutingInfoState() {
+ return other.getRoutingInfoState();
+ }
+
+ @Override
+ public ColorContrastCheckState getColorContrastCheckState() {
+ return other.getColorContrastCheckState();
+ }
+
+ @Override
+ public ConstraintsProvider getConstraintsProvider() {
+ return other.getConstraintsProvider();
+ }
+
+ @Override
+ public CarHostConfig getCarHostConfig() {
+ return other.getCarHostConfig();
+ }
+
+ @Override
+ public HostResourceIds getHostResourceIds() {
+ return other.getHostResourceIds();
+ }
+
+ @Override
+ public AppBindingStateProvider getAppBindingStateProvider() {
+ return other.getAppBindingStateProvider();
+ }
+
+ @Override
+ public boolean registerAppHostService(
+ Class<? extends AppHostService> clazz, AppHostService appHostService) {
+ return other.registerAppHostService(clazz, appHostService);
+ }
+
+ @Override
+ @Nullable
+ public <T extends AppHostService> T getAppHostService(Class<T> clazz) {
+ // TODO(b/169182143): Make single use type services use this getter.
+ return other.getAppHostService(clazz);
+ }
+ };
+ }
+
+ /**
+ * Provides the package information such as accent colors, component names etc. associated with
+ * the 3p app.
+ */
+ public abstract CarAppPackageInfo getCarAppPackageInfo();
+
+ /** Provides the {@link InputManager} for the current car activity to bring up the keyboard. */
+ public abstract InputManager getInputManager();
+
+ /** Provides the {@link ErrorHandler} for displaying errors to the user. */
+ public abstract ErrorHandler getErrorHandler();
+
+ /** Provides the {@link ANRHandler} for managing ANRs. */
+ public abstract ANRHandler getAnrHandler();
+
+ /** Provides the {@link BackPressedHandler} for dispatching back press events to the app. */
+ public abstract BackPressedHandler getBackPressedHandler();
+
+ /** Provides the {@link SurfaceCallbackHandler} for dispatching surface callbacks to the app. */
+ public abstract SurfaceCallbackHandler getSurfaceCallbackHandler();
+
+ /** Provides the {@link InputConfig} to access the input configuration. */
+ public abstract InputConfig getInputConfig();
+
+ /**
+ * Provides the {@link StatusBarManager} to allow for overriding the status bar background and
+ * text color.
+ */
+ public abstract StatusBarManager getStatusBarManager();
+
+ /** Provides the {@link SurfaceInfoProvider} to allow storing and retrieving safe area insets. */
+ public abstract SurfaceInfoProvider getSurfaceInfoProvider();
+
+ /** Provides the {@link EventManager} to allow dispatching and subscribing to different events. */
+ public abstract EventManager getEventManager();
+
+ /** Provides the {@link AppDispatcher} which allows dispatching IPCs to the client app. */
+ public abstract AppDispatcher getAppDispatcher();
+
+ /** Returns the system {@link SystemClockWrapper}. */
+ public abstract SystemClockWrapper getSystemClockWrapper();
+
+ /** Returns the {@link ToastController} which allows clients to show toasts. */
+ public abstract ToastController getToastController();
+
+ /**
+ * Returns a {@link Context} instance for the remote app, configured with this context's
+ * configuration (which includes configuration from the car's resources, such as screen size and
+ * DPI).
+ *
+ * <p>The theme in this context is also set to the application's theme id, so that attributes in
+ * remote resources can be resolved using the that theme (see {@link
+ * ColorUtils#loadThemeId(Context, ComponentName)}).
+ *
+ * <p>Use method to load drawable resources from app's APKs, so that they are returned with the
+ * target DPI of the car screen, rather than the phone's. See b/159088813 for more details.
+ *
+ * @return the remote app's context, or {@code null} if unavailable due to an error (the logcat
+ * will contain a log with the error in this case).
+ */
+ @Nullable
+ public abstract Context getAppConfigurationContext();
+
+ /** Returns the {@link CarAppManager} that is to be used for starting and finishing car apps. */
+ public abstract CarAppManager getCarAppManager();
+
+ /**
+ * Updates the {@link Configuration} of the app configuration context that is retrieved via {@link
+ * #getAppConfigurationContext}, and publishes a {@link
+ * EventManager.EventType#CONFIGURATION_CHANGED} event using the {@link EventManager}.
+ */
+ public abstract void updateConfiguration(Configuration configuration);
+
+ /** Returns the {@link TelemetryHandler} instance that allows reporting telemetry data. */
+ public abstract TelemetryHandler getTelemetryHandler();
+
+ /** Returns the {@link DebugOverlayHandler} instance that updating the debug overlay. */
+ public abstract DebugOverlayHandler getDebugOverlayHandler();
+
+ /**
+ * Returns the {@link RoutingInfoState} that keeps track of the routing information state across
+ * template apps.
+ */
+ // TODO(b/169182143): Use a generic getService model to retrieve this object
+ public abstract RoutingInfoState getRoutingInfoState();
+
+ /**
+ * Returns the {@link RoutingInfoState} that keeps track of the color contrast check state in the
+ * current template.
+ */
+ public abstract ColorContrastCheckState getColorContrastCheckState();
+
+ /**
+ * Returns the {@link ConstraintsProvider} that can provide the limits associated with this car
+ * app.
+ */
+ public abstract ConstraintsProvider getConstraintsProvider();
+
+ /**
+ * Returns a {@link CarHostConfig} object containing a series of flags and configuration options
+ */
+ public abstract CarHostConfig getCarHostConfig();
+
+ /** Produces a status report for this context, used for diagnostics and logging. */
+ public void reportStatus(PrintWriter pw) {}
+
+ /** Returns the {@link HostResourceIds} to use for this host implementation */
+ public abstract HostResourceIds getHostResourceIds();
+
+ /** Returns the {@link AppBindingStateProvider} instance. */
+ public abstract AppBindingStateProvider getAppBindingStateProvider();
+
+ /**
+ * Dynamically registers a {@link AppHostService}.
+ *
+ * @return {@code true} if register is successful, {@code false} if the service already exists.
+ */
+ public boolean registerAppHostService(
+ Class<? extends AppHostService> clazz, AppHostService appHostService) {
+ if (mAppHostServices.containsKey(clazz)) {
+ return false;
+ }
+
+ mAppHostServices.put(clazz, appHostService);
+ return true;
+ }
+
+ /**
+ * Returns the {@link AppHostService} of the requested class, or {@code null} if it does not exist
+ * for this host.
+ */
+ @SuppressWarnings({"unchecked", "cast.unsafe"}) // Cannot check if instanceof T
+ @Nullable
+ public <T extends AppHostService> T getAppHostService(Class<T> clazz) {
+ return (T) mAppHostServices.get(clazz);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ThreadUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ThreadUtils.java
new file mode 100644
index 0000000..25c0985
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ThreadUtils.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car.libraries.apphost.common;
+
+import android.os.Handler;
+import android.os.Looper;
+
+/** Utility functions to handle running functions on the main thread. */
+public class ThreadUtils {
+ private static final Handler HANDLER = new Handler(Looper.getMainLooper());
+
+ /** Field assignment is atomic in java and we are only checking reference equality. */
+ private static Thread sMainThread;
+
+ /** Executes the {@code action} on the main thread. */
+ public static void runOnMain(Runnable action) {
+ if (Looper.getMainLooper() == Looper.myLooper()) {
+ action.run();
+ } else {
+ HANDLER.post(action);
+ }
+ }
+
+ /** Enqueues the {@code action} to the message queue on the main thread. */
+ public static void enqueueOnMain(Runnable action) {
+ HANDLER.post(action);
+ }
+
+ /**
+ * Checks that currently running on the main thread.
+ *
+ * @throws IllegalStateException if the current thread is not the main thread
+ */
+ public static void checkMainThread() {
+ if (Looper.getMainLooper() != Looper.myLooper()) {
+ throw new IllegalStateException("Not running on main thread when it is required to.");
+ }
+ }
+
+ /** Returns true if the current thread is the UI thread. */
+ public static boolean getsMainThread() {
+ if (sMainThread == null) {
+ sMainThread = Looper.getMainLooper().getThread();
+ }
+ return Thread.currentThread() == sMainThread;
+ }
+
+ /** Checks that the current thread is the UI thread. Otherwise throws an exception. */
+ public static void ensureMainThread() {
+ if (!getsMainThread()) {
+ throw new AssertionError("Must be called on the UI thread");
+ }
+ }
+
+ private ThreadUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ToastController.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ToastController.java
new file mode 100644
index 0000000..a1ebafe
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/common/ToastController.java
@@ -0,0 +1,31 @@
+/*
+ * 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.common;
+
+import android.widget.Toast;
+
+/** Allows controlling the toasts on car screen. */
+public interface ToastController {
+ /**
+ * Shows the Toast view with the specified text for the specified duration.
+ *
+ * @param text the text message to be displayed
+ * @param duration how long to display the message. Either {@link Toast#LENGTH_SHORT} or {@link
+ * Toast#LENGTH_LONG}
+ */
+ void showToast(CharSequence text, int duration);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/BackFlowViolationException.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/BackFlowViolationException.java
new file mode 100644
index 0000000..242591a
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/BackFlowViolationException.java
@@ -0,0 +1,23 @@
+/*
+ * 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.distraction;
+
+/** A {@link FlowViolationException} that indicates an incorrect back flow. */
+public class BackFlowViolationException extends FlowViolationException {
+ BackFlowViolationException(String message) {
+ super(message);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/FlowViolationException.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/FlowViolationException.java
new file mode 100644
index 0000000..c0685d0
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/FlowViolationException.java
@@ -0,0 +1,23 @@
+/*
+ * 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.distraction;
+
+/** Wrapper class for exceptions that indicate template flow violations. */
+public abstract class FlowViolationException extends Exception {
+ protected FlowViolationException(String message) {
+ super(message);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/OverLimitFlowViolationException.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/OverLimitFlowViolationException.java
new file mode 100644
index 0000000..4892bf7
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/OverLimitFlowViolationException.java
@@ -0,0 +1,23 @@
+/*
+ * 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.distraction;
+
+/** A {@link FlowViolationException} that indicates the flow is over the max limit. */
+public class OverLimitFlowViolationException extends FlowViolationException {
+ protected OverLimitFlowViolationException(String message) {
+ super(message);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/TemplateValidator.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/TemplateValidator.java
new file mode 100644
index 0000000..04d5118
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/TemplateValidator.java
@@ -0,0 +1,513 @@
+/*
+ * 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.distraction;
+
+import android.content.Context;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.LongMessageTemplate;
+import androidx.car.app.model.MessageTemplate;
+import androidx.car.app.model.PaneTemplate;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateInfo;
+import androidx.car.app.model.TemplateWrapper;
+import androidx.car.app.model.signin.SignInTemplate;
+import androidx.car.app.navigation.model.NavigationTemplate;
+import com.android.car.libraries.apphost.common.AppHostService;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.distraction.checkers.TemplateChecker;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.Map;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A class for validating whether an app's template flow abide by the flow rules.
+ *
+ * <p>The host should call {@link #validateFlow} to check whether a new template is allowed in the
+ * flow, governed by the following rules:
+ *
+ * <ul>
+ * <li>BACK: if the new template contains the same ID and type as another template that have
+ * already been seen, it is considered a back operation, and the step count will be reset to
+ * the value used for the previously-seen template.
+ * <li>REFRESH: if the new template does not contain different immutable contents compared to the
+ * most recent template, it is considered a refresh and the step count will not increased.
+ * <li>NEW: Otherwise, the template is considered a new view and is only allowed if the given step
+ * limit has not been reached. If the template is allowed and is a consumption view, as
+ * defined by {@link #isConsumptionView}, the step count is reset and the next new template
+ * will start from a step count of zero again.
+ * </ul>
+ *
+ * <p>See go/watevra-distraction-part1 for more details.
+ */
+public class TemplateValidator implements AppHostService {
+ private final int mStepLimit;
+ private @Nullable TemplateWrapper mLastTemplateWrapper;
+ private final Deque<TemplateStackItem> mTemplateItemStack = new ArrayDeque<>();
+
+ /**
+ * When set to the true, the next template received for validation will have its step reset tot
+ * zero (e.g. the template will be considered the start of a new task).
+ */
+ private boolean mIsReset;
+
+ /**
+ * When set to the true, the next template received for validation will be considered a refresh
+ * regardless of content as long as it is of the same type.
+ */
+ private boolean mIsNextTemplateContentRefreshIfSameType;
+
+ /**
+ * The step count of the last sent template.
+ *
+ * <p>Note that this value is 1-based. For example, the first template is step 1.
+ */
+ private int mLastStep;
+
+ private final Map<Class<? extends Template>, TemplateChecker<? extends Template>>
+ mTemplateCheckerMap = new HashMap<>();
+
+ /** Constructs a {@link TemplateValidator} instance with a given maximum number of steps. */
+ public static TemplateValidator create(int stepLimit) {
+ return new TemplateValidator(stepLimit);
+ }
+
+ /**
+ * Registers a {@link TemplateChecker} to be used for the template type during the {@link
+ * #validateFlow} operation.
+ */
+ public <T extends Template> void registerTemplateChecker(
+ Class<T> templateClass, TemplateChecker<T> checker) {
+ mTemplateCheckerMap.put(templateClass, checker);
+ }
+
+ /** Reset the current step count on the next template received. */
+ public void reset() {
+ // Note that we don't clear the stack here. The host needs to keep track of the templates
+ // it has seen, so that it can compare the list of TemplateInfo inside TemplateWrapper,
+ // and not count them after the refresh. See b/179085934 for more details.
+ // Additionally, we don't reset mLastTemplateWrapper since that will mean navigating out of
+ // the app (IE to the launcher) and back will cause the template to be recreated rather than
+ // refreshed.
+ mIsReset = true;
+ }
+
+ /**
+ * Sets whether the next template should be considered a refresh as long as it is of the same
+ * type.
+ */
+ public void setIsNextTemplateContentRefreshIfSameType(boolean isContentRefresh) {
+ mIsNextTemplateContentRefreshIfSameType = isContentRefresh;
+ }
+
+ /** Whether the next template should be considered a refresh as long as it is of the same type. */
+ @VisibleForTesting
+ boolean isNextTemplateContentRefreshIfSameType() {
+ return mIsNextTemplateContentRefreshIfSameType;
+ }
+
+ /** Returns the step count that was used for the last template. */
+ @VisibleForTesting
+ public int getLastStep() {
+ return mLastStep;
+ }
+
+ /** Returns whether the validator will reset the step count on the next template received. */
+ @VisibleForTesting
+ public boolean isPendingReset() {
+ return mIsReset;
+ }
+
+ @Override
+ public String toString() {
+ return "[ step limit: " + mStepLimit + ", last step: " + mLastStep + "]";
+ }
+
+ /**
+ * Validates whether the application has the required permissions for this template.
+ *
+ * @throws SecurityException if the application is missing any required permission
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"}) // ignoring TemplateChecker raw type warnings.
+ public void validateHasRequiredPermissions(
+ TemplateContext templateContext, TemplateWrapper templateWrapper) {
+ Template template = templateWrapper.getTemplate();
+
+ TemplateChecker checker = mTemplateCheckerMap.get(template.getClass());
+ if (checker == null) {
+ throw new IllegalStateException(
+ "Permission check failed. No checker has been registered for the template"
+ + " type: "
+ + template);
+ }
+
+ Context appConfigurationContext = templateContext.getAppConfigurationContext();
+ if (appConfigurationContext == null) {
+ L.d(
+ LogTags.DISTRACTION,
+ "Permission check failed. No app configuration context is registered.");
+ // If we do not have a context for the car app do not allow due to missing
+ // permissions, this is a bad state.
+ throw new IllegalStateException(
+ "Could not validate whether the app has required permissions");
+ }
+
+ checker.checkPermissions(appConfigurationContext, template);
+ }
+
+ /**
+ * Validates whether the given {@link TemplateWrapper} meets the flow restriction requirements.
+ *
+ * @throws FlowViolationException if the new template contains the same ID as a previously seen
+ * template but is of a different template type
+ * @throws FlowViolationException if the step limit has been reached and the template is not
+ */
+ public void validateFlow(TemplateWrapper templateWrapper) throws FlowViolationException {
+ fillInBackStackIfNeeded(templateWrapper);
+
+ boolean isNextTemplateContentRefreshIfSameType = mIsNextTemplateContentRefreshIfSameType;
+ mIsNextTemplateContentRefreshIfSameType = false;
+
+ // Order is important here. We want to make sure we check for back first because there
+ // might be cases when an app goes back from one template to another, the content changes
+ // might satisfy the refresh conditions, thus keeping the current step instead of
+ // decrementing to the previous step.
+ if (validateBackFlow(templateWrapper)
+ || validateRefreshFlow(templateWrapper, isNextTemplateContentRefreshIfSameType)) {
+ mLastTemplateWrapper = templateWrapper;
+ return;
+ }
+
+ // Before we check whether a new template is allowed, check whether a reset should happen so
+ // we don't prematurely disallow the next template.
+ Template template = templateWrapper.getTemplate();
+
+ // Parked-only template should not increment step count.
+ int currentStep = isParkedOnlyTemplate(template.getClass()) ? mLastStep : mLastStep + 1;
+ currentStep = resetTaskStepIfNeeded(currentStep, template.getClass());
+
+ throwIfNewTemplateDisallowed(currentStep, templateWrapper);
+
+ L.d(
+ LogTags.DISTRACTION,
+ "NEW template detected. Task step currently at %d of %d. %s",
+ currentStep,
+ mStepLimit,
+ templateWrapper);
+
+ templateWrapper.setCurrentTaskStep(currentStep);
+ mTemplateItemStack.push(
+ new TemplateStackItem(
+ templateWrapper.getId(), template.getClass(), templateWrapper.getCurrentTaskStep()));
+ mLastTemplateWrapper = templateWrapper;
+ mLastStep = currentStep;
+ }
+
+ private void fillInBackStackIfNeeded(TemplateWrapper templateWrapper) {
+ // The template infos are ordered as follows: top, second, third, bottom
+ // Look through our known stack, if there are more than 1 templates in the top that we do
+ // not currently have, we need to add them to our stack.
+ //
+ // If there is 1 extra template, it'll be handled by the pushing logic in validateFlow.
+ //
+ // If there the top template ids are the same, it will be handled by the logic in
+ // validateRefreshFlow.
+ //
+ // If there are less in the client provided stack, it will be handled by the logic in
+ // validateBackFlow.
+ Deque<TemplateInfo> newTemplates = new ArrayDeque<>();
+ for (TemplateInfo templateInfo : templateWrapper.getTemplateInfosForScreenStack()) {
+ // For each not known template push it onto a separate stack, so that after all the
+ // pushes, it will be ordered as follows:
+ //
+ // i.e. if the client has 3 new templates that the host does not know about this
+ // temporary stack will be third, second, top
+ if (findExistingTemplateStackItem(templateInfo.getTemplateId()) == null) {
+ newTemplates.push(templateInfo);
+ } else {
+ break;
+ }
+ }
+
+ // At this point the "newTemplates" stack contains any values they are new templates that
+ // the host does not know about.
+ // We do not need to push the bottom of this new stack, as that is the new template which
+ // will be handled by validateFlow.
+ while (newTemplates.size() > 1) {
+ // Set last template wrapper to null so that we don't check if the new one is possibly a
+ // refresh since we are preseeding templates in between the current top and the new top.
+ mLastTemplateWrapper = null;
+ TemplateInfo info = newTemplates.pop();
+ Class<? extends Template> templateClass = info.getTemplateClass();
+
+ // Parked-only template should not increment step count.
+ int currentStep = isParkedOnlyTemplate(templateClass) ? mLastStep : mLastStep + 1;
+ currentStep = resetTaskStepIfNeeded(currentStep, templateClass);
+ mTemplateItemStack.push(
+ new TemplateStackItem(info.getTemplateId(), templateClass, currentStep));
+ mLastStep = currentStep;
+ }
+ }
+
+ /**
+ * Returns {@code true} if the given {@link TemplateWrapper} is a refresh of the last-sent
+ * template based on the registered {@link TemplateChecker}, or {@code false otherwise}.
+ *
+ * <p>Note that if a {@link TemplateChecker} is not available for a template type, the {@link
+ * #validateFlow} operation will return false by default.
+ *
+ * <p>A template is considered a refresh if it is of the same template type and does not have data
+ * that we consider immutable as compared to the previous template. If the input template is
+ * deemed a refresh, the task step count will be changed.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"}) // ignoring TemplateChecker raw type warnings.
+ private boolean validateRefreshFlow(
+ TemplateWrapper templateWrapper, boolean isNextTemplateContentRefreshIfSameType) {
+ TemplateWrapper lastTemplateWrapper = mLastTemplateWrapper;
+ TemplateStackItem lastTemplateStackItem = mTemplateItemStack.peek();
+ if (lastTemplateWrapper == null || lastTemplateStackItem == null) {
+ return false;
+ }
+
+ Template lastTemplate = lastTemplateWrapper.getTemplate();
+ Template newTemplate = templateWrapper.getTemplate();
+
+ TemplateChecker checker = mTemplateCheckerMap.get(newTemplate.getClass());
+ if (checker == null) {
+ L.d(
+ LogTags.DISTRACTION,
+ "REFRESH check failed. No checker has been registered for the template type:" + " %s",
+ newTemplate.getClass());
+ return false;
+ }
+
+ if (lastTemplate.getClass() != newTemplate.getClass()) {
+ L.d(
+ LogTags.DISTRACTION,
+ "REFRESH check failed. Template type differs (previous: %s, new: %s).",
+ lastTemplateWrapper,
+ templateWrapper);
+ return false;
+ }
+
+ if (isNextTemplateContentRefreshIfSameType || checker.isRefresh(newTemplate, lastTemplate)) {
+ int currentStep =
+ resetTaskStepIfNeeded(lastTemplateStackItem.getStep(), newTemplate.getClass());
+ templateWrapper.setCurrentTaskStep(currentStep);
+ templateWrapper.setRefresh(true);
+ mLastStep = currentStep;
+
+ // We push the new template as a new stack item so that we can keep track of the refresh
+ // stack. This is needed to handle a case where if a template is refreshed across
+ // multiple screens (e.g. same template content, different template ids), when the app
+ // pops back to a previous screen and sends the previous template, the host will
+ // recognize the id in the stack and consider it a back operation. (See b/160892144 for
+ // more context).
+ mTemplateItemStack.push(
+ new TemplateStackItem(
+ templateWrapper.getId(),
+ newTemplate.getClass(),
+ templateWrapper.getCurrentTaskStep()));
+
+ L.d(
+ LogTags.DISTRACTION,
+ "REFRESH detected. Task step currently at %d of %d. %s",
+ templateWrapper.getCurrentTaskStep(),
+ mStepLimit,
+ templateWrapper);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks whether the given {@link TemplateWrapper} is a back operation. Returns {@code true} if
+ * so, {@code false otherwise}.
+ *
+ * <p>A template is considered a back operation if it is of the same template type and the same ID
+ * as a template that is already on the stack. If the input template is deemed to be a back
+ * operation, method will pop any templates on the stack above the target template we are going
+ * back to, and reset the task step count to the value held by the target template.
+ *
+ * @throws FlowViolationException if the target template with the matching ID is of a different
+ * template type
+ */
+ private boolean validateBackFlow(TemplateWrapper templateWrapper) throws FlowViolationException {
+ String id = templateWrapper.getId();
+ Template template = templateWrapper.getTemplate();
+
+ // This detects the case where the app is popping screens (e.g. going back).
+ // If there is a template with a matching ID and type in the stack, pop everything
+ // above the found item, then update the template and set the task step to the value at that
+ // found item.
+ TemplateStackItem foundItem = findExistingTemplateStackItem(id);
+ if (foundItem != null) {
+ if (foundItem.getTemplateClass() != template.getClass()) {
+ throw new BackFlowViolationException(
+ String.format(
+ "BACK operation failed. Template types differ (previous: %s, new:" + " %s).",
+ foundItem, templateWrapper));
+ }
+
+ // A special case where if the found template is already at the top of stack, then
+ // it is not a back, but a refresh (e.g. an app sending the same template as before).
+ if (foundItem == mTemplateItemStack.peek()) {
+ return false;
+ }
+
+ while (foundItem != mTemplateItemStack.peek()) {
+ mTemplateItemStack.pop();
+ }
+
+ L.d(
+ LogTags.DISTRACTION,
+ "BACK detected. Task step currently at %d of %d. %s",
+ foundItem.getStep(),
+ mStepLimit,
+ templateWrapper);
+
+ // Set the task step back to the value of the found template the app is going back to.
+ int currentStep = resetTaskStepIfNeeded(foundItem.getStep(), template.getClass());
+ templateWrapper.setCurrentTaskStep(currentStep);
+ mLastStep = currentStep;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the {@link TemplateStackItem} currently in the stack with the given ID, or {@code null}
+ * if none is found.
+ */
+ private @Nullable TemplateStackItem findExistingTemplateStackItem(String id) {
+ TemplateStackItem foundItem = null;
+ for (TemplateStackItem stackItem : mTemplateItemStack) {
+ if (stackItem.getTemplateId().equals(id)) {
+ foundItem = stackItem;
+ break;
+ }
+ }
+
+ return foundItem;
+ }
+
+ /**
+ * Validates that we still have budget for a new template, throw otherwise. If it is the last step
+ * in the flow, also validates that only certain template classes are allowed, throw otherwise.
+ */
+ private void throwIfNewTemplateDisallowed(int nextStepToUse, TemplateWrapper templateWrapper)
+ throws OverLimitFlowViolationException {
+ // Check that we still have quota.
+ if (nextStepToUse > mStepLimit) {
+ throw new OverLimitFlowViolationException(
+ String.format("No template allowed after %d templates. %s", mStepLimit, templateWrapper));
+ }
+
+ // Special case for the last step - only certain template types are supported.
+ // 1. For NavigationTemplates, they are consumption view so they will reset the step count.
+ // 2. For SignInTemplates and LongMessageTemplates, they are parked-only and will not
+ // increase the step count.
+ // 3. PaneTemplates and MessageTemplates are the only other two templates that are allowed
+ // at the end of a task.
+ if (nextStepToUse == mStepLimit) {
+ Class<? extends Template> templateClass = templateWrapper.getTemplate().getClass();
+ if (!(templateClass.equals(NavigationTemplate.class)
+ || templateClass.equals(PaneTemplate.class)
+ || templateClass.equals(MessageTemplate.class)
+ || templateClass.equals(SignInTemplate.class)
+ || templateClass.equals(LongMessageTemplate.class))) {
+ throw new OverLimitFlowViolationException(
+ String.format(
+ "Unsupported template type as the last step in a task. %s", templateWrapper));
+ }
+ }
+ }
+
+ private TemplateValidator(int stepLimit) {
+ mStepLimit = stepLimit;
+ }
+
+ /**
+ * Returns the task step that should be used for the next template, resetting it to 1 if a reset
+ * has been requested or if the template is a "consumption view".
+ */
+ private int resetTaskStepIfNeeded(int taskStep, Class<? extends Template> templateClass) {
+ if (mIsReset || isConsumptionView(templateClass)) {
+ taskStep = 1;
+ L.d(LogTags.DISTRACTION, "Resetting task step to %d. %s", taskStep, templateClass.getName());
+ mIsReset = false;
+ }
+
+ return taskStep;
+ }
+
+ /**
+ * Returns whether the given {@link Template} is a "consumption view".
+ *
+ * <p>Consumption views are defined as “sit-and-stay” experiences. In our library's context, these
+ * is the {@link NavigationTemplate}, and can be extended to other templates such as media
+ * playback and in-call view templates in the future when we support them.
+ */
+ private static boolean isConsumptionView(Class<? extends Template> templateClass) {
+ boolean isConsumptionTemplate = NavigationTemplate.class.equals(templateClass);
+ if (isConsumptionTemplate) {
+ L.d(LogTags.DISTRACTION, "Consumption template detected. %s", templateClass.getName());
+ }
+ return isConsumptionTemplate;
+ }
+
+ /** Returns whether the given {@link Template} is a parked-only template. */
+ private static boolean isParkedOnlyTemplate(Class<? extends Template> templateClass) {
+ boolean isParkedOnly =
+ SignInTemplate.class.equals(templateClass)
+ || LongMessageTemplate.class.equals(templateClass);
+ if (isParkedOnly) {
+ L.d(LogTags.DISTRACTION, "Parked only template detected. %s", templateClass.getName());
+ }
+ return isParkedOnly;
+ }
+
+ /** Structure contain the template information to be stored onto the stack. */
+ private static class TemplateStackItem {
+ private final String mTemplateid;
+ private final Class<? extends Template> mTemplateClass;
+ private final int mStep;
+
+ TemplateStackItem(String templateid, Class<? extends Template> templateClass, int step) {
+ mTemplateid = templateid;
+ mTemplateClass = templateClass;
+ mStep = step;
+ }
+
+ String getTemplateId() {
+ return mTemplateid;
+ }
+
+ Class<? extends Template> getTemplateClass() {
+ return mTemplateClass;
+ }
+
+ int getStep() {
+ return mStep;
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/CheckerUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/CheckerUtils.java
new file mode 100644
index 0000000..706c227
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/CheckerUtils.java
@@ -0,0 +1,130 @@
+/*
+ * 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.distraction.checkers;
+
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarText;
+import androidx.car.app.model.GridItem;
+import androidx.car.app.model.Item;
+import androidx.car.app.model.Row;
+import androidx.car.app.model.Toggle;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import java.util.List;
+import java.util.Objects;
+
+/** Shared util methods for handling different template checking logic. */
+public class CheckerUtils {
+ /** Returns whether the sizes and string contents of the two lists of items are equal. */
+ public static <T extends Item> boolean itemsHaveSameContent(
+ List<T> itemList1, List<T> itemList2) {
+ if (itemList1.size() != itemList2.size()) {
+ L.d(
+ LogTags.DISTRACTION,
+ "REFRESH check failed. Different item list sizes. Old: %d. New: %d",
+ itemList1.size(),
+ itemList2.size());
+ return false;
+ }
+
+ for (int i = 0; i < itemList1.size(); i++) {
+ T itemObj1 = itemList1.get(i);
+ T itemObj2 = itemList2.get(i);
+
+ if (itemObj1.getClass() != itemObj2.getClass()) {
+ L.d(
+ LogTags.DISTRACTION,
+ "REFRESH check failed. Different item types at index %d. Old: %s. New: %s",
+ i,
+ itemObj1.getClass(),
+ itemObj2.getClass());
+ return false;
+ }
+
+ if (itemObj1 instanceof Row) {
+ if (!rowsHaveSameContent((Row) itemObj1, (Row) itemObj2, i)) {
+ return false;
+ }
+ } else if (itemObj1 instanceof GridItem) {
+ if (!gridItemsHaveSameContent((GridItem) itemObj1, (GridItem) itemObj2, i)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /** Returns whether the string contents of the two rows are equal. */
+ private static boolean rowsHaveSameContent(Row row1, Row row2, int index) {
+ // Special case for rows with toggles - if the toggle state has changed, then text updates
+ // are allowed.
+ if (toggleStateHasChanged(row1.getToggle(), row2.getToggle())) {
+ return true;
+ }
+
+ if (!carTextsHasSameString(row1.getTitle(), row2.getTitle())) {
+ L.d(
+ LogTags.DISTRACTION,
+ "REFRESH check failed. Different row titles at index %d. Old: %s. New: %s",
+ index,
+ row1.getTitle(),
+ row2.getTitle());
+ return false;
+ }
+
+ return true;
+ }
+
+ /** Returns whether the string contents of the two grid items are equal. */
+ private static boolean gridItemsHaveSameContent(
+ GridItem gridItem1, GridItem gridItem2, int index) {
+ // We only check the item's title - changes in text and image are considered a refresh.
+ if (!carTextsHasSameString(gridItem1.getTitle(), gridItem2.getTitle())) {
+ L.d(
+ LogTags.DISTRACTION,
+ "REFRESH check failed. Different grid item titles at index %d. Old: %s. New:" + " %s",
+ index,
+ gridItem1.getTitle(),
+ gridItem2.getTitle());
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns whether the strings of the two {@link CarText}s are the same.
+ *
+ * <p>Spans that are attached to the strings are ignored from the comparison.
+ */
+ private static boolean carTextsHasSameString(
+ @Nullable CarText carText1, @Nullable CarText carText2) {
+ // If both carText1 and carText2 are null, return true. If only one of them is null, return
+ // false.
+ if (carText1 == null || carText2 == null) {
+ return carText1 == null && carText2 == null;
+ }
+
+ return Objects.equals(carText1.toString(), carText2.toString());
+ }
+
+ private static boolean toggleStateHasChanged(@Nullable Toggle toggle1, @Nullable Toggle toggle2) {
+ return toggle1 != null && toggle2 != null && toggle1.isChecked() != toggle2.isChecked();
+ }
+
+ private CheckerUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/GridTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/GridTemplateChecker.java
new file mode 100644
index 0000000..fecd3f0
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/GridTemplateChecker.java
@@ -0,0 +1,57 @@
+/*
+ * 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.distraction.checkers;
+
+import androidx.car.app.model.GridTemplate;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.model.Toggle;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link GridTemplate} */
+public class GridTemplateChecker implements TemplateChecker<GridTemplate> {
+ /**
+ * A new template is considered a refresh of the old if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title has not changed, and the number of grid items and the string contents
+ * (title, texts) of each grid item have not changed.
+ * <li>For grid items that contain a {@link Toggle}, updates to the title, text and image are
+ * also allowed if the toggle state has changed between the previous and new templates.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(GridTemplate newTemplate, GridTemplate oldTemplate) {
+ if (oldTemplate.isLoading()) {
+ // Transition from a previous loading state is allowed.
+ return true;
+ } else if (newTemplate.isLoading()) {
+ // Transition to a loading state is disallowed.
+ return false;
+ }
+ if (!Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())) {
+ return false;
+ }
+
+ ItemList oldList = oldTemplate.getSingleList();
+ ItemList newList = newTemplate.getSingleList();
+ if (oldList != null && newList != null) {
+ return CheckerUtils.itemsHaveSameContent(oldList.getItems(), newList.getItems());
+ }
+
+ return true;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/ListTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/ListTemplateChecker.java
new file mode 100644
index 0000000..a3023f2
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/ListTemplateChecker.java
@@ -0,0 +1,88 @@
+/*
+ * 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.distraction.checkers;
+
+import androidx.car.app.model.Item;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.model.ListTemplate;
+import androidx.car.app.model.SectionedItemList;
+import androidx.car.app.model.Toggle;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link ListTemplate} */
+public class ListTemplateChecker implements TemplateChecker<ListTemplate> {
+ /**
+ * A new template is considered a refresh of the old if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title has not changed, and the {@link ItemList} structure between the
+ * templates have not changed. This means that if the previous template has multiple {@link
+ * ItemList} sections, the new template must have the same number of sections with the same
+ * headers. Further, the number of rows and the string contents (title, texts, not counting
+ * spans) of each row must not have changed.
+ * <li>For rows that contain a {@link Toggle}, updates to the title or texts are also allowed if
+ * the toggle state has changed between the previous and new templates.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(ListTemplate newTemplate, ListTemplate oldTemplate) {
+ if (oldTemplate.isLoading()) {
+ // Transition from a previous loading state is allowed.
+ return true;
+ } else if (newTemplate.isLoading()) {
+ // Transition to a loading state is disallowed.
+ return false;
+ }
+
+ if (!Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())) {
+ return false;
+ }
+
+ ItemList oldList = oldTemplate.getSingleList();
+ ItemList newList = newTemplate.getSingleList();
+ if (oldList != null && newList != null) {
+ return CheckerUtils.itemsHaveSameContent(oldList.getItems(), newList.getItems());
+ } else {
+ List<SectionedItemList> oldSectionedList = oldTemplate.getSectionedLists();
+ List<SectionedItemList> newSectionedList = newTemplate.getSectionedLists();
+
+ if (oldSectionedList.size() != newSectionedList.size()) {
+ return false;
+ }
+
+ for (int i = 0; i < newSectionedList.size(); i++) {
+ SectionedItemList newSection = newSectionedList.get(i);
+ SectionedItemList oldSection = oldSectionedList.get(i);
+
+ ItemList oldItemList = oldSection.getItemList();
+ ItemList newItemList = newSection.getItemList();
+ List<Item> oldSubList =
+ oldItemList == null ? Collections.emptyList() : oldItemList.getItems();
+ List<Item> newSubList =
+ newItemList == null ? Collections.emptyList() : newItemList.getItems();
+ if (!Objects.equals(newSection.getHeader(), oldSection.getHeader())
+ || !CheckerUtils.itemsHaveSameContent(oldSubList, newSubList)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/MessageTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/MessageTemplateChecker.java
new file mode 100644
index 0000000..b3c698a
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/MessageTemplateChecker.java
@@ -0,0 +1,45 @@
+/*
+ * 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.distraction.checkers;
+
+import androidx.car.app.model.MessageTemplate;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link MessageTemplate} */
+public class MessageTemplateChecker implements TemplateChecker<MessageTemplate> {
+ /**
+ * A new template is considered a refresh of a previous one if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title and messages have not changed.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(MessageTemplate newTemplate, MessageTemplate oldTemplate) {
+ if (oldTemplate.isLoading()) {
+ // Transition from a previous loading state is allowed.
+ return true;
+ } else if (newTemplate.isLoading()) {
+ // Transition to a loading state is not considered a refresh.
+ return false;
+ }
+
+ return Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())
+ && Objects.equals(oldTemplate.getDebugMessage(), newTemplate.getDebugMessage())
+ && Objects.equals(oldTemplate.getMessage(), newTemplate.getMessage());
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/NavigationTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/NavigationTemplateChecker.java
new file mode 100644
index 0000000..596dec5
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/NavigationTemplateChecker.java
@@ -0,0 +1,34 @@
+/*
+ * 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.distraction.checkers;
+
+import android.content.Context;
+import androidx.car.app.CarAppPermission;
+import androidx.car.app.navigation.model.NavigationTemplate;
+
+/** A {@link TemplateChecker} implementation for {@link NavigationTemplate} */
+public class NavigationTemplateChecker implements TemplateChecker<NavigationTemplate> {
+ @Override
+ public boolean isRefresh(NavigationTemplate newTemplate, NavigationTemplate oldTemplate) {
+ // Always allow routing template refreshes.
+ return true;
+ }
+
+ @Override
+ public void checkPermissions(Context context, NavigationTemplate newTemplate) {
+ CarAppPermission.checkHasLibraryPermission(context, CarAppPermission.NAVIGATION_TEMPLATES);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PaneTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PaneTemplateChecker.java
new file mode 100644
index 0000000..cf07650
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PaneTemplateChecker.java
@@ -0,0 +1,50 @@
+/*
+ * 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.distraction.checkers;
+
+import androidx.car.app.model.Pane;
+import androidx.car.app.model.PaneTemplate;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link PaneTemplate} */
+public class PaneTemplateChecker implements TemplateChecker<PaneTemplate> {
+ /**
+ * A new template is considered a refresh of the old if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title has not changed, and the number of rows and the string contents
+ * (title, texts, not counting spans) of each row between the previous and new {@link Pane}s
+ * have not changed.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(PaneTemplate newTemplate, PaneTemplate oldTemplate) {
+ Pane oldPane = oldTemplate.getPane();
+ Pane newPane = newTemplate.getPane();
+ if (oldPane.isLoading()) {
+ return true;
+ } else if (newPane.isLoading()) {
+ return false;
+ }
+
+ if (!Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())) {
+ return false;
+ }
+
+ return CheckerUtils.itemsHaveSameContent(oldPane.getRows(), newPane.getRows());
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListMapTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListMapTemplateChecker.java
new file mode 100644
index 0000000..edaf6e7
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListMapTemplateChecker.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.distraction.checkers;
+
+import android.Manifest.permission;
+import android.content.Context;
+import androidx.car.app.CarAppPermission;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.model.PlaceListMapTemplate;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link PlaceListMapTemplate} */
+public class PlaceListMapTemplateChecker implements TemplateChecker<PlaceListMapTemplate> {
+ /**
+ * A new template is considered a refresh of the old if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title has not changed, and the number of rows and the string contents
+ * (title, texts, not counting spans) of each row between the previous and new {@link
+ * ItemList}s have not changed.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(PlaceListMapTemplate newTemplate, PlaceListMapTemplate oldTemplate) {
+ if (oldTemplate.isLoading()) {
+ // Transition from a previous loading state is allowed.
+ return true;
+ } else if (newTemplate.isLoading()) {
+ // Transition to a loading state is disallowed.
+ return false;
+ }
+
+ if (!Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())) {
+ return false;
+ }
+
+ ItemList oldList = oldTemplate.getItemList();
+ ItemList newList = newTemplate.getItemList();
+ if (oldList != null && newList != null) {
+ return CheckerUtils.itemsHaveSameContent(oldList.getItems(), newList.getItems());
+ }
+
+ return true;
+ }
+
+ @Override
+ public void checkPermissions(Context context, PlaceListMapTemplate newTemplate) {
+ if (newTemplate.isCurrentLocationEnabled()) {
+ CarAppPermission.checkHasPermission(context, permission.ACCESS_FINE_LOCATION);
+ }
+
+ CarAppPermission.checkHasLibraryPermission(context, CarAppPermission.MAP_TEMPLATES);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListNavigationTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListNavigationTemplateChecker.java
new file mode 100644
index 0000000..7bf534b
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/PlaceListNavigationTemplateChecker.java
@@ -0,0 +1,65 @@
+/*
+ * 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.distraction.checkers;
+
+import android.content.Context;
+import androidx.car.app.CarAppPermission;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.navigation.model.PlaceListNavigationTemplate;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link PlaceListNavigationTemplate} */
+public class PlaceListNavigationTemplateChecker
+ implements TemplateChecker<PlaceListNavigationTemplate> {
+ /**
+ * A new template is considered a refresh of the old if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title has not changed, and the number of rows and the string contents
+ * (title, texts, not counting spans) of each row between the previous and new {@link
+ * ItemList}s have not changed.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(
+ PlaceListNavigationTemplate newTemplate, PlaceListNavigationTemplate oldTemplate) {
+ if (oldTemplate.isLoading()) {
+ // Transition from a previous loading state is allowed.
+ return true;
+ } else if (newTemplate.isLoading()) {
+ // Transition to a loading state is disallowed.
+ return false;
+ }
+
+ if (!Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())) {
+ return false;
+ }
+
+ ItemList oldList = oldTemplate.getItemList();
+ ItemList newList = newTemplate.getItemList();
+ if (oldList != null && newList != null) {
+ return CheckerUtils.itemsHaveSameContent(oldList.getItems(), newList.getItems());
+ }
+
+ return true;
+ }
+
+ @Override
+ public void checkPermissions(Context context, PlaceListNavigationTemplate newTemplate) {
+ CarAppPermission.checkHasLibraryPermission(context, CarAppPermission.NAVIGATION_TEMPLATES);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/RoutePreviewNavigationTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/RoutePreviewNavigationTemplateChecker.java
new file mode 100644
index 0000000..8c70ac1
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/RoutePreviewNavigationTemplateChecker.java
@@ -0,0 +1,65 @@
+/*
+ * 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.distraction.checkers;
+
+import android.content.Context;
+import androidx.car.app.CarAppPermission;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link RoutePreviewNavigationTemplate} */
+public class RoutePreviewNavigationTemplateChecker
+ implements TemplateChecker<RoutePreviewNavigationTemplate> {
+ /**
+ * A new template is considered a refresh of the old if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title has not changed, and the number of rows and the string contents
+ * (title, texts, not counting spans) of each row between the previous and new {@link
+ * ItemList}s have not changed.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(
+ RoutePreviewNavigationTemplate newTemplate, RoutePreviewNavigationTemplate oldTemplate) {
+ if (oldTemplate.isLoading()) {
+ // Transition from a previous loading state is allowed.
+ return true;
+ } else if (newTemplate.isLoading()) {
+ // Transition to a loading state is disallowed.
+ return false;
+ }
+
+ if (!Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())) {
+ return false;
+ }
+
+ ItemList oldList = oldTemplate.getItemList();
+ ItemList newList = newTemplate.getItemList();
+ if (oldList != null && newList != null) {
+ return CheckerUtils.itemsHaveSameContent(oldList.getItems(), newList.getItems());
+ }
+
+ return true;
+ }
+
+ @Override
+ public void checkPermissions(Context context, RoutePreviewNavigationTemplate newTemplate) {
+ CarAppPermission.checkHasLibraryPermission(context, CarAppPermission.NAVIGATION_TEMPLATES);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/SignInTemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/SignInTemplateChecker.java
new file mode 100644
index 0000000..3de3493
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/SignInTemplateChecker.java
@@ -0,0 +1,61 @@
+/*
+ * 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.distraction.checkers;
+
+import androidx.car.app.model.signin.InputSignInMethod;
+import androidx.car.app.model.signin.SignInTemplate;
+import java.util.Objects;
+
+/** A {@link TemplateChecker} implementation for {@link SignInTemplate} */
+public class SignInTemplateChecker implements TemplateChecker<SignInTemplate> {
+ /**
+ * A new template is considered a refresh of a previous one if:
+ *
+ * <ul>
+ * <li>The previous template is in a loading state, or
+ * <li>The template title and instructions have not changed and the input method is the same
+ * type.
+ * </ul>
+ */
+ @Override
+ public boolean isRefresh(SignInTemplate newTemplate, SignInTemplate oldTemplate) {
+ if (oldTemplate.isLoading()) {
+ // Transition from a previous loading state is allowed.
+ return true;
+ } else if (newTemplate.isLoading()) {
+ // Transition to a loading state is not considered a refresh.
+ return false;
+ }
+ boolean equalSignInMethods =
+ Objects.equals(
+ oldTemplate.getSignInMethod().getClass(), newTemplate.getSignInMethod().getClass());
+
+ if (equalSignInMethods && oldTemplate.getSignInMethod() instanceof InputSignInMethod) {
+ InputSignInMethod oldMethod = (InputSignInMethod) oldTemplate.getSignInMethod();
+ InputSignInMethod newMethod = (InputSignInMethod) newTemplate.getSignInMethod();
+
+ equalSignInMethods =
+ oldMethod.getKeyboardType() == newMethod.getKeyboardType()
+ && Objects.equals(oldMethod.getHint(), newMethod.getHint())
+ && oldMethod.getInputType() == newMethod.getInputType();
+ }
+
+ return Objects.equals(oldTemplate.getTitle(), newTemplate.getTitle())
+ && Objects.equals(oldTemplate.getInstructions(), newTemplate.getInstructions())
+ && Objects.equals(oldTemplate.getAdditionalText(), newTemplate.getAdditionalText())
+ && equalSignInMethods;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/TemplateChecker.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/TemplateChecker.java
new file mode 100644
index 0000000..c3474a6
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/checkers/TemplateChecker.java
@@ -0,0 +1,37 @@
+/*
+ * 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.distraction.checkers;
+
+import android.content.Context;
+import androidx.car.app.model.Template;
+
+/**
+ * Used for checking template of the specified type within the distraction framework to see if they
+ * meet certain criteria (e.g. whether they are refreshes).
+ *
+ * @param <T> the type of template to check
+ */
+public interface TemplateChecker<T extends Template> {
+ /** Returns whether the {@code newTemplate} is a refresh of the {@code oldTemplate}. */
+ boolean isRefresh(T newTemplate, T oldTemplate);
+
+ /**
+ * Checks that the application has the required permissions for this template.
+ *
+ * @throws SecurityException if the application is missing any required permissions
+ */
+ default void checkPermissions(Context context, T newTemplate) {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ActionsConstraints.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ActionsConstraints.java
new file mode 100644
index 0000000..ab54fc7
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ActionsConstraints.java
@@ -0,0 +1,161 @@
+/*
+ * 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.distraction.constraints;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.Action;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Encapsulates the constraints to apply when rendering a list of {@link Action}s on a template. */
+public class ActionsConstraints {
+ /** Conservative constraints for most template types. */
+ private static final ActionsConstraints ACTIONS_CONSTRAINTS_CONSERVATIVE =
+ ActionsConstraints.builder().setMaxActions(2).build();
+
+ /**
+ * Constraints for template headers, where only the special-purpose back and app-icon standard
+ * actions are allowed.
+ */
+ public static final ActionsConstraints ACTIONS_CONSTRAINTS_HEADER =
+ ActionsConstraints.builder().setMaxActions(1).addDisallowedAction(Action.TYPE_CUSTOM).build();
+
+ /** Default constraints that should be applied to most templates (2 actions, 1 can have title). */
+ public static final ActionsConstraints ACTIONS_CONSTRAINTS_SIMPLE =
+ ACTIONS_CONSTRAINTS_CONSERVATIVE.newBuilder().setMaxCustomTitles(1).build();
+
+ /** Constraints for navigation templates. */
+ public static final ActionsConstraints ACTIONS_CONSTRAINTS_NAVIGATION =
+ ACTIONS_CONSTRAINTS_CONSERVATIVE
+ .newBuilder()
+ .setMaxActions(4)
+ .setMaxCustomTitles(1)
+ .addRequiredAction(Action.TYPE_CUSTOM)
+ .build();
+
+ /** Constraints for navigation templates. */
+ public static final ActionsConstraints ACTIONS_CONSTRAINTS_NAVIGATION_MAP =
+ ACTIONS_CONSTRAINTS_CONSERVATIVE.newBuilder().setMaxActions(4).build();
+
+ private final int mMaxActions;
+ private final int mMaxCustomTitles;
+ private final Set<Integer> mRequiredActionTypes;
+ private final Set<Integer> mDisallowedActionTypes;
+
+ /** Returns a builder of {@link ActionsConstraints}. */
+ @VisibleForTesting
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns a new builder that contains the same data as this {@link ActionsConstraints} instance,
+ */
+ @VisibleForTesting
+ public Builder newBuilder() {
+ return new Builder(this);
+ }
+
+ /** Returns the max number of actions allowed. */
+ public int getMaxActions() {
+ return mMaxActions;
+ }
+
+ /** Returns the max number of actions with custom titles allowed. */
+ public int getMaxCustomTitles() {
+ return mMaxCustomTitles;
+ }
+
+ /** Adds the set of required action types. */
+ @NonNull
+ public Set<Integer> getRequiredActionTypes() {
+ return mRequiredActionTypes;
+ }
+
+ /** Adds the set of disallowed action types. */
+ @NonNull
+ public Set<Integer> getDisallowedActionTypes() {
+ return mDisallowedActionTypes;
+ }
+
+ /** A builder of {@link ActionsConstraints}. */
+ @VisibleForTesting
+ public static class Builder {
+ private int mMaxActions = Integer.MAX_VALUE;
+ private int mMaxCustomTitles;
+ private final Set<Integer> mRequiredActionTypes = new HashSet<>();
+ private final Set<Integer> mDisallowedActionTypes = new HashSet<>();
+
+ /** Sets the maximum number of actions allowed. */
+ public Builder setMaxActions(int maxActions) {
+ mMaxActions = maxActions;
+ return this;
+ }
+
+ /** Sets the maximum number of actions with custom titles allowed. */
+ public Builder setMaxCustomTitles(int maxCustomTitles) {
+ mMaxCustomTitles = maxCustomTitles;
+ return this;
+ }
+
+ /** Adds an action type to the set of required types. */
+ public Builder addRequiredAction(int actionType) {
+ mRequiredActionTypes.add(actionType);
+ return this;
+ }
+
+ /** Adds an action type to the set of disallowed types. */
+ public Builder addDisallowedAction(int actionType) {
+ mDisallowedActionTypes.add(actionType);
+ return this;
+ }
+
+ /** TODO(b/174880910): Adding javadoc for AOSP */
+ public ActionsConstraints build() {
+ return new ActionsConstraints(this);
+ }
+
+ private Builder() {}
+
+ private Builder(ActionsConstraints constraints) {
+ mMaxActions = constraints.mMaxActions;
+ mMaxCustomTitles = constraints.mMaxCustomTitles;
+ mRequiredActionTypes.addAll(constraints.mRequiredActionTypes);
+ mDisallowedActionTypes.addAll(constraints.mDisallowedActionTypes);
+ }
+ }
+
+ private ActionsConstraints(Builder builder) {
+ mMaxActions = builder.mMaxActions;
+ mMaxCustomTitles = builder.mMaxCustomTitles;
+ mRequiredActionTypes = new HashSet<>(builder.mRequiredActionTypes);
+
+ if (!builder.mDisallowedActionTypes.isEmpty()) {
+ Set<Integer> disallowedActionTypes = new HashSet<>(builder.mDisallowedActionTypes);
+ disallowedActionTypes.retainAll(mRequiredActionTypes);
+ if (!disallowedActionTypes.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Disallowed action types cannot also be in the required set.");
+ }
+ }
+ mDisallowedActionTypes = new HashSet<>(builder.mDisallowedActionTypes);
+
+ if (mRequiredActionTypes.size() > mMaxActions) {
+ throw new IllegalArgumentException("Required action types exceeded max allowed actions.");
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarColorConstraints.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarColorConstraints.java
new file mode 100644
index 0000000..6809e08
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarColorConstraints.java
@@ -0,0 +1,78 @@
+/*
+ * 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.distraction.constraints;
+
+import androidx.car.app.model.CarColor;
+import androidx.car.app.model.CarColor.CarColorType;
+import java.util.HashSet;
+
+/** Encapsulates the constraints to apply when rendering a {@link CarColor} on a template. */
+public class CarColorConstraints {
+ public static final CarColorConstraints UNCONSTRAINED =
+ CarColorConstraints.create(
+ new int[] {
+ CarColor.TYPE_CUSTOM,
+ CarColor.TYPE_DEFAULT,
+ CarColor.TYPE_PRIMARY,
+ CarColor.TYPE_SECONDARY,
+ CarColor.TYPE_RED,
+ CarColor.TYPE_GREEN,
+ CarColor.TYPE_BLUE,
+ CarColor.TYPE_YELLOW
+ });
+
+ public static final CarColorConstraints STANDARD_ONLY =
+ CarColorConstraints.create(
+ new int[] {
+ CarColor.TYPE_DEFAULT,
+ CarColor.TYPE_PRIMARY,
+ CarColor.TYPE_SECONDARY,
+ CarColor.TYPE_RED,
+ CarColor.TYPE_GREEN,
+ CarColor.TYPE_BLUE,
+ CarColor.TYPE_YELLOW
+ });
+
+ public static final CarColorConstraints NO_COLOR = CarColorConstraints.create(new int[] {});
+
+ @SuppressWarnings("RestrictTo")
+ @CarColorType
+ private final HashSet<Integer> mAllowedTypes;
+
+ private static CarColorConstraints create(int[] allowedColorTypes) {
+ return new CarColorConstraints(allowedColorTypes);
+ }
+
+ /**
+ * Returns whether the {@link CarColor} meets the constraints' requirement.
+ *
+ * @throws IllegalArgumentException if the color type is not allowed
+ */
+ @SuppressWarnings("RestrictTo")
+ public void validateOrThrow(CarColor carColor) {
+ @CarColorType int type = carColor.getType();
+ if (!mAllowedTypes.contains(type)) {
+ throw new IllegalArgumentException("Car color type is not allowed: " + carColor);
+ }
+ }
+
+ private CarColorConstraints(int[] allowedColorTypes) {
+ mAllowedTypes = new HashSet<>();
+ for (int type : allowedColorTypes) {
+ mAllowedTypes.add(type);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarIconConstraints.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarIconConstraints.java
new file mode 100644
index 0000000..a0ea196
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/CarIconConstraints.java
@@ -0,0 +1,86 @@
+/*
+ * 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.distraction.constraints;
+
+import android.content.ContentResolver;
+
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarIcon;
+import androidx.core.graphics.drawable.IconCompat;
+
+/** Encapsulates the constraints to apply when rendering a {@link CarIcon} on a template. */
+public class CarIconConstraints {
+ /** Allow all custom icon types. */
+ public static final CarIconConstraints UNCONSTRAINED =
+ CarIconConstraints.create(
+ new int[] {
+ IconCompat.TYPE_BITMAP, IconCompat.TYPE_RESOURCE, IconCompat.TYPE_URI
+ });
+
+ /** By default, do not allow custom icon types that would load asynchronously in the host. */
+ public static final CarIconConstraints DEFAULT =
+ CarIconConstraints.create(new int[] {IconCompat.TYPE_BITMAP, IconCompat.TYPE_RESOURCE});
+
+ private final int[] mAllowedTypes;
+
+ private static CarIconConstraints create(int[] allowedCustomIconTypes) {
+ return new CarIconConstraints(allowedCustomIconTypes);
+ }
+
+ /**
+ * Returns whether the {@link CarIcon} meets the constraints' requirement.
+ *
+ * @throws IllegalStateException if the custom icon does not have a backing {@link IconCompat}
+ * instance
+ * @throws IllegalArgumentException if the custom icon type is not allowed
+ */
+ public void validateOrThrow(@Nullable CarIcon carIcon) {
+ if (carIcon == null || carIcon.getType() != CarIcon.TYPE_CUSTOM) {
+ return;
+ }
+
+ IconCompat iconCompat = carIcon.getIcon();
+ if (iconCompat == null) {
+ throw new IllegalStateException("Custom icon does not have a backing IconCompat");
+ }
+
+ checkSupportedIcon(iconCompat);
+ }
+
+ /**
+ * Checks whether the given icon is supported.
+ *
+ * @throws IllegalArgumentException if the given icon type is unsupported
+ */
+ public IconCompat checkSupportedIcon(IconCompat iconCompat) {
+ int type = iconCompat.getType();
+ for (int allowedType : mAllowedTypes) {
+ if (type == allowedType) {
+ if (type == IconCompat.TYPE_URI
+ && !ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(
+ iconCompat.getUri().getScheme())) {
+ throw new IllegalArgumentException("Unsupported URI scheme for: " + iconCompat);
+ }
+ return iconCompat;
+ }
+ }
+ throw new IllegalArgumentException("Custom icon type is not allowed: " + type);
+ }
+
+ private CarIconConstraints(int[] allowedCustomIconTypes) {
+ mAllowedTypes = allowedCustomIconTypes;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ConstraintsProvider.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ConstraintsProvider.java
new file mode 100644
index 0000000..777a1af
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/ConstraintsProvider.java
@@ -0,0 +1,50 @@
+/*
+ * 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.distraction.constraints;
+
+/** Used to provide different limit values for the car app. */
+public interface ConstraintsProvider {
+ /** Provides the max length this car app can use for a content type. */
+ default int getContentLimit(int contentType) {
+ return 0;
+ }
+
+ /** Provides the max size for the template stack for this car app. */
+ default int getTemplateStackMaxSize() {
+ return 0;
+ }
+
+ /** Provides the max length this car app can use for a text view */
+ default int getStringCharacterLimit() {
+ return Integer.MAX_VALUE;
+ }
+
+ /** Returns true if keyboard is restricted for this car app */
+ default boolean isKeyboardRestricted() {
+ return false;
+ }
+
+ /** Returns true if config is restricted for this car app */
+ default boolean isConfigRestricted() {
+ return false;
+ }
+
+ /** Returns true if filtering is restricted for this car app */
+ default boolean isFilteringRestricted() {
+ return false;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowConstraints.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowConstraints.java
new file mode 100644
index 0000000..5d68adc
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowConstraints.java
@@ -0,0 +1,176 @@
+/*
+ * 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.distraction.constraints;
+
+/**
+ * Encapsulates the constraints to apply when rendering a {@link androidx.car.app.model.Row} in
+ * different contexts.
+ */
+public class RowConstraints {
+ public static final RowConstraints UNCONSTRAINED = RowConstraints.builder().build();
+
+ /** Conservative constraints for a row. */
+ public static final RowConstraints ROW_CONSTRAINTS_CONSERVATIVE =
+ RowConstraints.builder()
+ .setMaxActionsExclusive(0)
+ .setImageAllowed(false)
+ .setMaxTextLinesPerRow(1)
+ .setOnClickListenerAllowed(true)
+ .setToggleAllowed(false)
+ .build();
+
+ /** The constraints for a full-width row in a pane. */
+ public static final RowConstraints ROW_CONSTRAINTS_PANE =
+ RowConstraints.builder()
+ .setMaxActionsExclusive(2)
+ .setImageAllowed(true)
+ .setMaxTextLinesPerRow(2)
+ .setToggleAllowed(false)
+ .setOnClickListenerAllowed(false)
+ .build();
+
+ /** The constraints for a simple row (2 rows of text and 1 image */
+ public static final RowConstraints ROW_CONSTRAINTS_SIMPLE =
+ RowConstraints.builder()
+ .setMaxActionsExclusive(0)
+ .setImageAllowed(true)
+ .setMaxTextLinesPerRow(2)
+ .setToggleAllowed(false)
+ .setOnClickListenerAllowed(true)
+ .build();
+
+ /** The constraints for a full-width row in a list (simple + toggle support). */
+ public static final RowConstraints ROW_CONSTRAINTS_FULL_LIST =
+ ROW_CONSTRAINTS_SIMPLE.newBuilder().setToggleAllowed(true).build();
+
+ private final int mMaxTextLinesPerRow;
+ private final int mMaxActionsExclusive;
+ private final boolean mIsImageAllowed;
+ private final boolean mIsToggleAllowed;
+ private final boolean mIsOnClickListenerAllowed;
+ private final CarIconConstraints mCarIconConstraints;
+
+ /** Returns a builder of {@link RowConstraints}. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** Returns a builder of {@link RowConstraints} set up with the information from this instance. */
+ public Builder newBuilder() {
+ return new Builder(this);
+ }
+
+ /** Returns whether the row can have a click listener associated with it. */
+ public boolean isOnClickListenerAllowed() {
+ return mIsOnClickListenerAllowed;
+ }
+
+ /** Returns the maximum number lines of text, excluding the title, to render in the row. */
+ public int getMaxTextLinesPerRow() {
+ return mMaxTextLinesPerRow;
+ }
+
+ /** Returns the maximum number actions to allowed in a row that consists only of actions. */
+ public int getMaxActionsExclusive() {
+ return mMaxActionsExclusive;
+ }
+
+ /** Returns whether a toggle can be added to the row. */
+ public boolean isToggleAllowed() {
+ return mIsToggleAllowed;
+ }
+
+ /** Returns whether an image can be added to the row. */
+ public boolean isImageAllowed() {
+ return mIsImageAllowed;
+ }
+
+ /** Returns the {@link CarIconConstraints} enforced for the row images. */
+ public CarIconConstraints getCarIconConstraints() {
+ return mCarIconConstraints;
+ }
+
+ private RowConstraints(Builder builder) {
+ mIsOnClickListenerAllowed = builder.mIsOnClickListenerAllowed;
+ mMaxTextLinesPerRow = builder.mMaxTextLines;
+ mMaxActionsExclusive = builder.mMaxActionsExclusive;
+ mIsToggleAllowed = builder.mIsToggleAllowed;
+ mIsImageAllowed = builder.mIsImageAllowed;
+ mCarIconConstraints = builder.mCarIconConstraints;
+ }
+
+ /** A builder of {@link RowConstraints}. */
+ public static class Builder {
+ private boolean mIsOnClickListenerAllowed = true;
+ private boolean mIsToggleAllowed = true;
+ private int mMaxTextLines = Integer.MAX_VALUE;
+ private int mMaxActionsExclusive = Integer.MAX_VALUE;
+ private boolean mIsImageAllowed = true;
+ private CarIconConstraints mCarIconConstraints = CarIconConstraints.UNCONSTRAINED;
+
+ /** Sets whether a click listener is allowed on the row. */
+ public Builder setOnClickListenerAllowed(boolean isOnClickListenerAllowed) {
+ mIsOnClickListenerAllowed = isOnClickListenerAllowed;
+ return this;
+ }
+
+ /** Sets the maximum number of text lines in a row. */
+ public Builder setMaxTextLinesPerRow(int maxTextLinesPerRow) {
+ mMaxTextLines = maxTextLinesPerRow;
+ return this;
+ }
+
+ /** Sets the maximum number actions to allowed in a row that consists only of actions. */
+ public Builder setMaxActionsExclusive(int maxActionsExclusive) {
+ mMaxActionsExclusive = maxActionsExclusive;
+ return this;
+ }
+
+ /** Sets whether an image can be added to the row. */
+ public Builder setImageAllowed(boolean imageAllowed) {
+ mIsImageAllowed = imageAllowed;
+ return this;
+ }
+
+ /** Sets whether a toggle can be added to the row. */
+ public Builder setToggleAllowed(boolean toggleAllowed) {
+ mIsToggleAllowed = toggleAllowed;
+ return this;
+ }
+
+ /** Sets the {@link CarIconConstraints} enforced for the row images. */
+ public Builder setCarIconConstraints(CarIconConstraints carIconConstraints) {
+ mCarIconConstraints = carIconConstraints;
+ return this;
+ }
+
+ /** Constructs a {@link RowConstraints} object from this builder. */
+ public RowConstraints build() {
+ return new RowConstraints(this);
+ }
+
+ private Builder() {}
+
+ private Builder(RowConstraints constraints) {
+ mIsOnClickListenerAllowed = constraints.mIsOnClickListenerAllowed;
+ mMaxTextLines = constraints.mMaxTextLinesPerRow;
+ mMaxActionsExclusive = constraints.mMaxActionsExclusive;
+ mIsToggleAllowed = constraints.mIsToggleAllowed;
+ mIsImageAllowed = constraints.mIsImageAllowed;
+ mCarIconConstraints = constraints.mCarIconConstraints;
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowListConstraints.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowListConstraints.java
new file mode 100644
index 0000000..0bb9c7d
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/distraction/constraints/RowListConstraints.java
@@ -0,0 +1,170 @@
+/*
+ * 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.distraction.constraints;
+
+import static androidx.car.app.constraints.ConstraintManager.CONTENT_LIMIT_TYPE_LIST;
+import static androidx.car.app.constraints.ConstraintManager.CONTENT_LIMIT_TYPE_PANE;
+import static androidx.car.app.constraints.ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST;
+import static com.android.car.libraries.apphost.distraction.constraints.RowConstraints.ROW_CONSTRAINTS_CONSERVATIVE;
+import static com.android.car.libraries.apphost.distraction.constraints.RowConstraints.ROW_CONSTRAINTS_FULL_LIST;
+import static com.android.car.libraries.apphost.distraction.constraints.RowConstraints.ROW_CONSTRAINTS_PANE;
+import static com.android.car.libraries.apphost.distraction.constraints.RowConstraints.ROW_CONSTRAINTS_SIMPLE;
+
+/** Encapsulates the constraints to apply when rendering a row list under different contexts. */
+public class RowListConstraints {
+ /** Conservative constraints for all types lists. */
+ public static final RowListConstraints ROW_LIST_CONSTRAINTS_CONSERVATIVE =
+ RowListConstraints.builder()
+ .setListContentType(CONTENT_LIMIT_TYPE_LIST)
+ .setMaxActions(0)
+ .setRowConstraints(ROW_CONSTRAINTS_CONSERVATIVE)
+ .setAllowSelectableLists(false)
+ .build();
+
+ /** Default constraints for heterogeneous pane of items, full width. */
+ public static final RowListConstraints ROW_LIST_CONSTRAINTS_PANE =
+ ROW_LIST_CONSTRAINTS_CONSERVATIVE
+ .newBuilder()
+ .setMaxActions(2)
+ .setListContentType(CONTENT_LIMIT_TYPE_PANE)
+ .setRowConstraints(ROW_CONSTRAINTS_PANE)
+ .setAllowSelectableLists(false)
+ .build();
+
+ /** Default constraints for uniform lists of items, no toggles. */
+ public static final RowListConstraints ROW_LIST_CONSTRAINTS_SIMPLE =
+ ROW_LIST_CONSTRAINTS_CONSERVATIVE
+ .newBuilder()
+ .setRowConstraints(ROW_CONSTRAINTS_SIMPLE)
+ .build();
+
+ /** Default constraints for the route preview card. */
+ public static final RowListConstraints ROW_LIST_CONSTRAINTS_ROUTE_PREVIEW =
+ ROW_LIST_CONSTRAINTS_CONSERVATIVE
+ .newBuilder()
+ .setListContentType(CONTENT_LIMIT_TYPE_ROUTE_LIST)
+ .setRowConstraints(ROW_CONSTRAINTS_SIMPLE)
+ .setAllowSelectableLists(true)
+ .build();
+
+ /** Default constraints for uniform lists of items, full width (simple + toggle support). */
+ public static final RowListConstraints ROW_LIST_CONSTRAINTS_FULL_LIST =
+ ROW_LIST_CONSTRAINTS_CONSERVATIVE
+ .newBuilder()
+ .setRowConstraints(ROW_CONSTRAINTS_FULL_LIST)
+ .setAllowSelectableLists(true)
+ .build();
+
+ private final int mListContentType;
+ private final int mMaxActions;
+ private final RowConstraints mRowConstraints;
+ private final boolean mAllowSelectableLists;
+
+ /** A builder of {@link RowListConstraints}. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns a builder of {@link RowListConstraints} set up with the information from this instance.
+ */
+ public Builder newBuilder() {
+ return new Builder(this);
+ }
+
+ /**
+ * Returns the list content type for this constraint.
+ *
+ * <p>This should be one of the content types as defined in {@link
+ * androidx.car.app.constraints.ConstraintManager}.
+ */
+ public int getListContentType() {
+ return mListContentType;
+ }
+
+ /** Returns the maximum number of actions allowed to be added alongside the list. */
+ public int getMaxActions() {
+ return mMaxActions;
+ }
+
+ /** Returns the constraints to apply on individual rows. */
+ public RowConstraints getRowConstraints() {
+ return mRowConstraints;
+ }
+
+ /** Returns whether radio lists are allowed. */
+ public boolean getAllowSelectableLists() {
+ return mAllowSelectableLists;
+ }
+
+ private RowListConstraints(Builder builder) {
+ mMaxActions = builder.mMaxActions;
+ mRowConstraints = builder.mRowConstraints;
+ mAllowSelectableLists = builder.mAllowSelectableLists;
+ mListContentType = builder.mListContentType;
+ }
+
+ /** A builder of {@link RowListConstraints}. */
+ public static class Builder {
+ private int mListContentType;
+ private int mMaxActions;
+ private RowConstraints mRowConstraints = RowConstraints.UNCONSTRAINED;
+ private boolean mAllowSelectableLists;
+
+ /**
+ * Sets the content type for this constraint.
+ *
+ * <p>This should be one of the content types as defined in {@link
+ * androidx.car.app.constraints.ConstraintManager}.
+ */
+ public Builder setListContentType(int contentType) {
+ mListContentType = contentType;
+ return this;
+ }
+
+ /** Sets the maximum number of actions allowed to be added alongside the list. */
+ public Builder setMaxActions(int maxActions) {
+ mMaxActions = maxActions;
+ return this;
+ }
+
+ /** Sets the constraints to apply on individual rows. */
+ public Builder setRowConstraints(RowConstraints rowConstraints) {
+ mRowConstraints = rowConstraints;
+ return this;
+ }
+
+ /** Sets whether radio lists are allowed. */
+ public Builder setAllowSelectableLists(boolean allowSelectableLists) {
+ mAllowSelectableLists = allowSelectableLists;
+ return this;
+ }
+
+ /** Constructs a {@link RowListConstraints} from this builder. */
+ public RowListConstraints build() {
+ return new RowListConstraints(this);
+ }
+
+ private Builder() {}
+
+ private Builder(RowListConstraints constraints) {
+ mMaxActions = constraints.mMaxActions;
+ mRowConstraints = constraints.mRowConstraints;
+ mAllowSelectableLists = constraints.mAllowSelectableLists;
+ mListContentType = constraints.mListContentType;
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditable.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditable.java
new file mode 100644
index 0000000..db843a9
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditable.java
@@ -0,0 +1,31 @@
+/*
+ * 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.input;
+
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+
+/** Views that implement this interface are editable by the IME system. */
+public interface CarEditable {
+ /** Notifies that the input connection has been created. */
+ InputConnection onCreateInputConnection(EditorInfo outAttrs);
+
+ /** Sets a listener for events related to input on this car editable. */
+ void setCarEditableListener(CarEditableListener listener);
+
+ /** Sets whether input is enabled. */
+ void setInputEnabled(boolean enabled);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditableListener.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditableListener.java
new file mode 100644
index 0000000..2577070
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/CarEditableListener.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.input;
+
+/**
+ * Callbacks from the {@link CarEditable} to the IME. These methods should be called on the main
+ * thread.
+ */
+public interface CarEditableListener {
+ /**
+ * Indicates that the selection has changed on the current {@link CarEditable}. Note that
+ * selection changes include cursor movements.
+ *
+ * @param oldSelStart the old selection starting index
+ * @param oldSelEnd the old selection ending index
+ * @param newSelStart the new selection starting index
+ * @param newSelEnd the new selection ending index
+ */
+ void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputConfig.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputConfig.java
new file mode 100644
index 0000000..896225d
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputConfig.java
@@ -0,0 +1,25 @@
+/*
+ * 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.input;
+
+/** Input configurations of the head unit. */
+public interface InputConfig {
+ /** Returns {@code true} if user can use touchpad to navigate UI, {@code false} otherwise. */
+ boolean hasTouchpadForUiNavigation();
+
+ /** Returns {@code true} if touch input is available, {@code false} otherwise. */
+ boolean hasTouch();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputManager.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputManager.java
new file mode 100644
index 0000000..6d8d13a
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/input/InputManager.java
@@ -0,0 +1,52 @@
+/*
+ * 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.input;
+
+/**
+ * Manages use of the in-car IME. All methods should only be called on the main thread.
+ * TODO(b/174880910): Use @MainThread here.
+ */
+public interface InputManager {
+ /**
+ * Starts input on the requested {@link CarEditable}, showing the IME. If IME input is already
+ * occurring for another view, this call stops input on the previous view and starts input on the
+ * new view.
+ *
+ * <p>This method must only be called from the UI thread. This method should not be called from a
+ * stopped activity.
+ */
+ void startInput(CarEditable view);
+
+ /**
+ * Stops input, hiding the IME. This method fails silently if the calling application didn't
+ * request input and isn't the active IME.
+ *
+ * <p>This function must only be called from the UI thread.
+ */
+ void stopInput();
+
+ /**
+ * Returns {@code true} while the {@link InputManager} is valid. The {@link InputManager} is valid
+ * as long as the activity from which it was obtained has been created and not destroyed.
+ */
+ boolean isValid();
+
+ /**
+ * Returns whether this {@link InputManager} is valid and the IME is active on the given {@link
+ * CarEditable}.
+ */
+ boolean isInputActive();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/ANRHandlerImpl.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/ANRHandlerImpl.java
new file mode 100644
index 0000000..8432d92
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/ANRHandlerImpl.java
@@ -0,0 +1,130 @@
+/*
+ * 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.internal;
+
+import static com.android.car.libraries.apphost.common.EventManager.EventType.APP_DISCONNECTED;
+import static com.android.car.libraries.apphost.common.EventManager.EventType.APP_UNBOUND;
+
+import android.content.ComponentName;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import com.android.car.libraries.apphost.common.ANRHandler;
+import com.android.car.libraries.apphost.common.CarAppError;
+import com.android.car.libraries.apphost.common.ErrorHandler;
+import com.android.car.libraries.apphost.common.EventManager;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+import com.android.car.libraries.apphost.logging.CarAppApiErrorType;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.logging.TelemetryHandler;
+
+/** Implementation of an {@link ANRHandler}. */
+public class ANRHandlerImpl implements ANRHandler {
+ private final Handler mHandler = new Handler(Looper.getMainLooper(), new HandlerCallback());
+ private final ComponentName mAppName;
+ private final TelemetryHandler mTelemetryhandler;
+ private final ErrorHandler mErrorHandler;
+
+ /** Creates an {@link ANRHandler} */
+ public static ANRHandler create(
+ ComponentName appName,
+ ErrorHandler errorHandler,
+ TelemetryHandler telemetryHandler,
+ EventManager eventManager) {
+ return new ANRHandlerImpl(appName, errorHandler, telemetryHandler, eventManager);
+ }
+
+ /**
+ * Performs the call and checks for application not responding.
+ *
+ * <p>The ANR check will happen in {@link #ANR_TIMEOUT_MS} milliseconds after calling {@link
+ * ANRCheckingCall#call}.
+ */
+ @Override
+ public void callWithANRCheck(CarAppApi carAppApi, ANRCheckingCall call) {
+ enqueueANRCheck(carAppApi);
+ call.call(
+ new ANRToken() {
+ @Override
+ public void dismiss() {
+ mHandler.removeMessages(carAppApi.ordinal());
+ }
+
+ @Override
+ public CarAppApi getCarAppApi() {
+ return carAppApi;
+ }
+ });
+ }
+
+ private void enqueueANRCheck(CarAppApi carAppApi) {
+ mHandler.removeMessages(carAppApi.ordinal());
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(carAppApi.ordinal()), ANR_TIMEOUT_MS);
+ }
+
+ private void onWaitClicked(CarAppApi carAppApi) {
+ enqueueANRCheck(carAppApi);
+ mErrorHandler.showError(
+ CarAppError.builder(mAppName).setType(CarAppError.Type.ANR_WAITING).build());
+ }
+
+ private void removeAllANRChecks() {
+ for (CarAppApi api : CarAppApi.values()) {
+ mHandler.removeMessages(api.ordinal());
+ }
+ }
+
+ @SuppressWarnings("nullness")
+ private ANRHandlerImpl(
+ ComponentName appName,
+ ErrorHandler errorHandler,
+ TelemetryHandler telemetryHandler,
+ EventManager eventManager) {
+ mAppName = appName;
+ mErrorHandler = errorHandler;
+ mTelemetryhandler = telemetryHandler;
+
+ // Remove any outstanding ANR check whenever the app becomes unbound or crashes.
+ eventManager.subscribeEvent(this, APP_UNBOUND, this::removeAllANRChecks);
+ eventManager.subscribeEvent(this, APP_DISCONNECTED, this::removeAllANRChecks);
+ }
+
+ /** A {@link Handler.Callback} used to implement unbinding. */
+ private class HandlerCallback implements Handler.Callback {
+ @Override
+ public boolean handleMessage(Message msg) {
+ final CarAppApi carAppApi = CarAppApi.values()[msg.what];
+ if (carAppApi == CarAppApi.UNKNOWN_API) {
+ L.w(LogTags.APP_HOST, "Unexpected message for handler %s", msg);
+ return false;
+ } else {
+ // Show an ANR screen allowing the user to wait.
+ // If the user wants to wait, we will show a waiting screen that still allows EXIT.
+ mTelemetryhandler.logCarAppApiFailureTelemetry(mAppName, carAppApi, CarAppApiErrorType.ANR);
+
+ mErrorHandler.showError(
+ CarAppError.builder(mAppName)
+ .setType(CarAppError.Type.ANR_TIMEOUT)
+ .setDebugMessage("ANR API: " + carAppApi.name())
+ .setExtraAction(() -> onWaitClicked(carAppApi))
+ .build());
+
+ return true;
+ }
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AndroidManifest.xml b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AndroidManifest.xml
new file mode 100644
index 0000000..69189b9
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AndroidManifest.xml
@@ -0,0 +1,5 @@
+<manifest package="com.android.car.libraries.apphost.internal"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-sdk android:minSdkVersion="23"/>
+</manifest>
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AppDispatcherImpl.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AppDispatcherImpl.java
new file mode 100644
index 0000000..ba7356c
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/AppDispatcherImpl.java
@@ -0,0 +1,385 @@
+/*
+ * 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.internal;
+
+import static com.android.car.libraries.apphost.logging.TelemetryHandler.getErrorType;
+
+import android.content.ComponentName;
+import android.graphics.Rect;
+import android.os.RemoteException;
+import androidx.car.app.FailureResponse;
+import androidx.car.app.ISurfaceCallback;
+import androidx.car.app.SurfaceContainer;
+import androidx.car.app.model.InputCallbackDelegate;
+import androidx.car.app.model.OnCheckedChangeDelegate;
+import androidx.car.app.model.OnClickDelegate;
+import androidx.car.app.model.OnContentRefreshDelegate;
+import androidx.car.app.model.OnItemVisibilityChangedDelegate;
+import androidx.car.app.model.OnSelectedDelegate;
+import androidx.car.app.model.SearchCallbackDelegate;
+import androidx.car.app.navigation.model.PanModeDelegate;
+import androidx.car.app.serialization.Bundleable;
+import androidx.car.app.serialization.BundlerException;
+import com.android.car.libraries.apphost.common.ANRHandler;
+import com.android.car.libraries.apphost.common.AppBindingStateProvider;
+import com.android.car.libraries.apphost.common.AppDispatcher;
+import com.android.car.libraries.apphost.common.CarAppError;
+import com.android.car.libraries.apphost.common.ErrorHandler;
+import com.android.car.libraries.apphost.common.OnDoneCallbackStub;
+import com.android.car.libraries.apphost.common.OneWayIPC;
+import com.android.car.libraries.apphost.internal.BlockingOneWayIPC.BlockingResponse;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.logging.TelemetryHandler;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Class to set up safe remote callbacks to apps.
+ *
+ * <p>App interfaces to client are {@code oneway} so the calling thread does not block waiting for a
+ * response.
+ */
+public class AppDispatcherImpl implements AppDispatcher {
+ /** A request to send over the wire to the app that does not wait for a ANR check. */
+ private interface OneWayIPCNoANRCheck {
+ void send() throws RemoteException;
+ }
+
+ private final ComponentName mAppName;
+ private final ErrorHandler mErrorHandler;
+ private final ANRHandler mANRHandler;
+ private final TelemetryHandler mTelemetryHandler;
+ private final AppBindingStateProvider mAppBindingStateProvider;
+
+ /** Creates an {@link AppDispatcher} instance for an app. */
+ public static AppDispatcher create(
+ ComponentName appName,
+ ErrorHandler errorHandler,
+ ANRHandler anrHandler,
+ TelemetryHandler telemetryHandler,
+ AppBindingStateProvider appBindingStateProvider) {
+ return new AppDispatcherImpl(
+ appName, errorHandler, anrHandler, telemetryHandler, appBindingStateProvider);
+ }
+
+ @Override
+ public void dispatchSurfaceAvailable(
+ ISurfaceCallback surfaceListener, SurfaceContainer surfaceContainer) {
+ dispatch(
+ anrToken ->
+ surfaceListener.onSurfaceAvailable(
+ Bundleable.create(surfaceContainer),
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_SURFACE_AVAILABLE);
+ }
+
+ @Override
+ public void dispatchSurfaceDestroyed(
+ ISurfaceCallback surfaceListener, SurfaceContainer surfaceContainer) {
+ // onSurfaceDestroyed is called blocking since the OS expects that whenever we return
+ // the call we are done using the Surface.
+ BlockingResponse<Void> blockingResponse = new BlockingResponse<>();
+ OneWayIPC ipc =
+ anrToken ->
+ surfaceListener.onSurfaceDestroyed(
+ Bundleable.create(surfaceContainer),
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider) {
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ blockingResponse.setResponse(null);
+ super.onSuccess(response);
+ }
+
+ @Override
+ public void onFailure(Bundleable failureResponse) {
+ blockingResponse.setResponse(null);
+ super.onFailure(failureResponse);
+ }
+ });
+
+ dispatch(new BlockingOneWayIPC<>(ipc, blockingResponse), CarAppApi.ON_SURFACE_DESTROYED);
+ }
+
+ @Override
+ public void dispatchVisibleAreaChanged(ISurfaceCallback surfaceListener, Rect visibleArea) {
+ dispatch(
+ anrToken ->
+ surfaceListener.onVisibleAreaChanged(
+ visibleArea,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_VISIBLE_AREA_CHANGED);
+ }
+
+ @Override
+ public void dispatchStableAreaChanged(ISurfaceCallback surfaceListener, Rect stableArea) {
+ dispatch(
+ anrToken ->
+ surfaceListener.onStableAreaChanged(
+ stableArea,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_STABLE_AREA_CHANGED);
+ }
+
+ @Override
+ public void dispatchOnSurfaceScroll(
+ ISurfaceCallback surfaceListener, float distanceX, float distanceY) {
+ dispatchNoANRCheck(() -> surfaceListener.onScroll(distanceX, distanceY), "onSurfaceScroll");
+ }
+
+ @Override
+ public void dispatchOnSurfaceFling(
+ ISurfaceCallback surfaceListener, float velocityX, float velocityY) {
+ dispatchNoANRCheck(() -> surfaceListener.onFling(velocityX, velocityY), "onSurfaceFling");
+ }
+
+ @Override
+ public void dispatchOnSurfaceScale(
+ ISurfaceCallback surfaceListener, float focusX, float focusY, float scaleFactor) {
+ dispatchNoANRCheck(
+ () -> surfaceListener.onScale(focusX, focusY, scaleFactor), "onSurfaceScale");
+ }
+
+ @Override
+ public void dispatchSearchTextChanged(
+ SearchCallbackDelegate searchCallbackDelegate, String searchText) {
+ dispatch(
+ anrToken ->
+ searchCallbackDelegate.sendSearchTextChanged(
+ searchText,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_SEARCH_TEXT_CHANGED);
+ }
+
+ @Override
+ public void dispatchInputTextChanged(
+ InputCallbackDelegate inputCallbackDelegate, String inputText) {
+ dispatch(
+ anrToken ->
+ inputCallbackDelegate.sendInputTextChanged(
+ inputText,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_INPUT_TEXT_CHANGED);
+ }
+
+ @Override
+ public void dispatchInputSubmitted(
+ InputCallbackDelegate inputCallbackDelegate, String inputText) {
+ dispatch(
+ anrToken ->
+ inputCallbackDelegate.sendInputSubmitted(
+ inputText,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_INPUT_SUBMITTED);
+ }
+
+ @Override
+ public void dispatchSearchSubmitted(
+ SearchCallbackDelegate searchCallbackDelegate, String searchText) {
+ dispatch(
+ anrToken ->
+ searchCallbackDelegate.sendSearchSubmitted(
+ searchText,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_SEARCH_SUBMITTED);
+ }
+
+ @Override
+ public void dispatchItemVisibilityChanged(
+ OnItemVisibilityChangedDelegate onItemVisibilityChangedDelegate,
+ int startIndexInclusive,
+ int endIndexExclusive) {
+ dispatch(
+ anrToken ->
+ onItemVisibilityChangedDelegate.sendItemVisibilityChanged(
+ startIndexInclusive,
+ endIndexExclusive,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_ITEM_VISIBILITY_CHANGED);
+ }
+
+ @Override
+ public void dispatchSelected(OnSelectedDelegate onSelectedDelegate, int index) {
+ dispatch(
+ anrToken ->
+ onSelectedDelegate.sendSelected(
+ index,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_SELECTED);
+ }
+
+ @Override
+ public void dispatchCheckedChanged(
+ OnCheckedChangeDelegate onCheckedChangeDelegate, boolean isChecked) {
+ dispatch(
+ anrToken ->
+ onCheckedChangeDelegate.sendCheckedChange(
+ isChecked,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_CHECKED_CHANGED);
+ }
+
+ @Override
+ public void dispatchPanModeChanged(PanModeDelegate panModeDelegate, boolean isChecked) {
+ dispatch(
+ anrToken ->
+ panModeDelegate.sendPanModeChanged(
+ isChecked,
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_PAN_MODE_CHANGED);
+ }
+
+ @Override
+ public void dispatchClick(OnClickDelegate onClickDelegate) {
+ dispatch(
+ anrToken ->
+ onClickDelegate.sendClick(
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_CLICK);
+ }
+
+ @Override
+ public void dispatchContentRefreshRequest(OnContentRefreshDelegate onContentRefreshDelegate) {
+ dispatch(
+ anrToken ->
+ onContentRefreshDelegate.sendContentRefreshRequested(
+ new OnDoneCallbackStub(
+ mErrorHandler,
+ mAppName,
+ anrToken,
+ mTelemetryHandler,
+ mAppBindingStateProvider)),
+ CarAppApi.ON_CLICK);
+ }
+
+ /** Dispatches the given IPC call without checking for an ANR. */
+ private void dispatchNoANRCheck(OneWayIPCNoANRCheck ipc, String callName) {
+ try {
+ ipc.send();
+ } catch (RemoteException e) {
+ mErrorHandler.showError(
+ CarAppError.builder(mAppName)
+ .setCause(e)
+ .setDebugMessage("Remote call " + callName + " failed.")
+ .build());
+ }
+ }
+
+ @Override
+ public void dispatch(OneWayIPC ipc, CarAppApi carAppApi) {
+ dispatch(ipc, mErrorHandler::showError, carAppApi);
+ }
+
+ @Override
+ public void dispatch(OneWayIPC ipc, ExceptionHandler exceptionHandler, CarAppApi carAppApi) {
+ L.d(LogTags.APP_HOST, "Dispatching call %s", carAppApi.name());
+
+ mANRHandler.callWithANRCheck(
+ carAppApi,
+ anrToken -> {
+ try {
+ ipc.send(anrToken);
+ } catch (RemoteException | BundlerException | RuntimeException e) {
+ mTelemetryHandler.logCarAppApiFailureTelemetry(
+ mAppName, carAppApi, getErrorType(new FailureResponse(e)));
+
+ exceptionHandler.handle(
+ CarAppError.builder(mAppName)
+ .setCause(e)
+ .setDebugMessage("Remote call " + carAppApi.name() + " failed.")
+ .build());
+ }
+ });
+ }
+
+ private AppDispatcherImpl(
+ ComponentName appName,
+ ErrorHandler errorHandler,
+ ANRHandler anrHandler,
+ TelemetryHandler telemetryHandler,
+ AppBindingStateProvider appBindingStateProvider) {
+ mAppName = appName;
+ mErrorHandler = errorHandler;
+ mANRHandler = anrHandler;
+ mTelemetryHandler = telemetryHandler;
+ mAppBindingStateProvider = appBindingStateProvider;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/BlockingOneWayIPC.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/BlockingOneWayIPC.java
new file mode 100644
index 0000000..5d801e8
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/BlockingOneWayIPC.java
@@ -0,0 +1,143 @@
+/*
+ * 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.internal;
+
+import android.os.RemoteException;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.serialization.BundlerException;
+import com.android.car.libraries.apphost.common.ANRHandler;
+import com.android.car.libraries.apphost.common.ANRHandler.ANRToken;
+import com.android.car.libraries.apphost.common.OneWayIPC;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeoutException;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A {@link OneWayIPC} that will block waiting for a response from the client before returning.
+ *
+ * <p>Once the client responds set the value received via calling {@link
+ * BlockingResponse#setResponse} on the {@link BlockingResponse} that was supplied.
+ *
+ * <p>When {@link #send} is called, the thread will be blocked up to {@link
+ * BlockingResponse#BLOCKING_MAX_MILLIS} milliseconds, or until the client responds, whichever comes
+ * first.
+ *
+ * <p>If the client does not respond until the timeout, the {@link ANRHandler} will display an ANR
+ * to the user.
+ *
+ * @param <T> the type of the response for the IPC
+ */
+public class BlockingOneWayIPC<T> implements OneWayIPC {
+ /**
+ * A class to block waiting on a response from the client.
+ *
+ * @param <T> the type of the response for the IPC
+ */
+ public static class BlockingResponse<T> {
+ // Set to 4 seconds instead of 5 seconds so the system does not ANR.
+ private static final long BLOCKING_MAX_MILLIS = 4000;
+ private static long sBlockingMaxMillis = BLOCKING_MAX_MILLIS;
+
+ @GuardedBy("this")
+ private boolean mComplete;
+
+ @GuardedBy("this")
+ @Nullable
+ private T mResponse;
+
+ /** Sets the response from the app, releasing any blocking threads. */
+ public void setResponse(@Nullable T response) {
+ synchronized (this) {
+ mResponse = response;
+ mComplete = true;
+ notifyAll();
+ }
+ }
+
+ /** Sets the maximum time to block the IPC for before considering it an ANR, in milliseconds. */
+ @VisibleForTesting
+ public static void setBlockingMaxMillis(long valueForTesting) {
+ sBlockingMaxMillis = valueForTesting;
+ }
+
+ /**
+ * Returns the value provided by calling {@link #setResponse}.
+ *
+ * <p>This method will block waiting for the client to call back before returning.
+ *
+ * <p>The max time method will wait is {@link #BLOCKING_MAX_MILLIS}.
+ */
+ @Nullable
+ private T getBlocking() throws TimeoutException, InterruptedException {
+ synchronized (this) {
+ long startedTimeMillis = System.currentTimeMillis();
+ long waitMillis = sBlockingMaxMillis;
+
+ while (!mComplete && waitMillis > 0) {
+ wait(waitMillis);
+
+ long elapsedMillis = System.currentTimeMillis() - startedTimeMillis;
+ waitMillis = sBlockingMaxMillis - elapsedMillis;
+ }
+ if (!mComplete) {
+ throw new TimeoutException("Response was not set while blocked");
+ }
+
+ return mResponse;
+ }
+ }
+ }
+
+ private final OneWayIPC mOneWayIPC;
+ private final BlockingResponse<T> mBlockingResponse;
+ @Nullable private T mResponse;
+
+ /** Constructs an instance of a {@link BlockingOneWayIPC}. */
+ public BlockingOneWayIPC(OneWayIPC oneWayIPC, BlockingResponse<T> blockingResponse) {
+ mOneWayIPC = oneWayIPC;
+ mBlockingResponse = blockingResponse;
+ }
+
+ @Override
+ public void send(ANRToken anrToken) throws BundlerException, RemoteException {
+ mOneWayIPC.send(anrToken);
+ try {
+ mResponse = mBlockingResponse.getBlocking();
+ anrToken.dismiss();
+ } catch (InterruptedException e) {
+ anrToken.dismiss();
+ throw new IllegalStateException("Exception while waiting for client response.", e);
+ } catch (TimeoutException e) {
+ L.w(LogTags.APP_HOST, e, "Timeout blocking for a client response");
+ // Let the ANR handler handle the ANR by not dismissing the token.
+ }
+ }
+
+ /**
+ * Returns the {@code Response} returned from the {@link Future} provided, or {@code null} if the
+ * app did not respond.
+ *
+ * <p>{@link #send} should be called before calling method, otherwise the result will be {@code
+ * null}.
+ */
+ @Nullable
+ public T getResponse() {
+ return mResponse;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBinding.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBinding.java
new file mode 100644
index 0000000..e142e78
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBinding.java
@@ -0,0 +1,625 @@
+/*
+ * 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.internal;
+
+import static com.android.car.libraries.apphost.common.EventManager.EventType.APP_DISCONNECTED;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.IInterface;
+import android.os.Looper;
+import android.os.Message;
+import android.os.RemoteException;
+import androidx.annotation.AnyThread;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.AppInfo;
+import androidx.car.app.CarContext;
+import androidx.car.app.HandshakeInfo;
+import androidx.car.app.IAppManager;
+import androidx.car.app.ICarApp;
+import androidx.car.app.ICarHost;
+import androidx.car.app.navigation.INavigationManager;
+import androidx.car.app.serialization.Bundleable;
+import androidx.car.app.serialization.BundlerException;
+import androidx.core.util.Consumer;
+import androidx.lifecycle.Lifecycle.Event;
+import com.android.car.libraries.apphost.common.ANRHandler.ANRToken;
+import com.android.car.libraries.apphost.common.AppDispatcher;
+import com.android.car.libraries.apphost.common.CarAppError;
+import com.android.car.libraries.apphost.common.CarHostConfig;
+import com.android.car.libraries.apphost.common.IncompatibleApiException;
+import com.android.car.libraries.apphost.common.IntentUtils;
+import com.android.car.libraries.apphost.common.NamedAppServiceCall;
+import com.android.car.libraries.apphost.common.OnDoneCallbackStub;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.common.ThreadUtils;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.logging.StatusReporter;
+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.io.PrintWriter;
+import java.security.InvalidParameterException;
+
+/** Manages a binding to the {@link ICarApp} and handles the communication with it. */
+public class CarAppBinding implements StatusReporter {
+
+ private static final int MSG_UNBIND = 1;
+ private static final int MSG_REBIND = 2;
+
+ private enum BindingState {
+ UNBOUND,
+ BINDING,
+ BOUND
+ }
+
+ private final Handler mMainHandler = new Handler(Looper.getMainLooper(), new HandlerCallback());
+ private final ComponentName mAppName;
+ private final ICarHost mCarHost;
+ private final CarAppBindingCallback mCarAppBindingCallback;
+ private final ServiceConnection mServiceConnection = new ServiceConnectionImpl();
+ private final TelemetryHandler mTelemetryHandler;
+
+ // The following fields can be updated by different threads, therefore they are volatile so that
+ // readers use the latest value.
+
+ private volatile TemplateContext mTemplateContext;
+
+ @Nullable private volatile ICarApp mCarApp;
+ @Nullable private volatile IInterface mAppManager;
+ @Nullable private volatile IInterface mNavigationManager;
+
+ @Nullable private volatile Intent mOriginalIntent;
+ @Nullable private volatile ANRToken mANRToken;
+
+ @Nullable private AppInfo mAppInfo;
+
+ /**
+ * The current state of the binding with the client app service. Use {@link
+ * #setBindingState(BindingState)} to update it.
+ */
+ private volatile BindingState mBindingState = BindingState.UNBOUND;
+
+ /**
+ * Creates a {@link CarAppBinding} for binding to and communicating with {@code appName}.
+ *
+ * @param templateContext the context to retrieve template helpers from
+ * @param carHost the host to send to the app when it is bound
+ * @param carAppBindingCallback callback to perform once the app is bound
+ */
+ public static CarAppBinding create(
+ TemplateContext templateContext,
+ ICarHost carHost,
+ CarAppBindingCallback carAppBindingCallback) {
+ return new CarAppBinding(templateContext, carHost, carAppBindingCallback);
+ }
+
+ @Override
+ public void reportStatus(PrintWriter pw, Pii piiHandling) {
+ pw.printf("- state: %s\n", mBindingState.name());
+ mTemplateContext.getCarHostConfig().reportStatus(pw, piiHandling);
+ }
+
+ /** Returns the name of the app this binding is managing. */
+ @AnyThread
+ public ComponentName getAppName() {
+ return mAppName;
+ }
+
+ @Override
+ public String toString() {
+ return "[" + mAppName.flattenToShortString() + ", state: " + mBindingState + "]";
+ }
+
+ /** Sets the {@link TemplateContext} instance attached to this binding. */
+ @AnyThread
+ public void setTemplateContext(TemplateContext templateContext) {
+ mTemplateContext = templateContext;
+
+ AppInfo appInfo = mAppInfo;
+ if (appInfo != null) {
+ try {
+ mTemplateContext.getCarHostConfig().updateNegotiatedApi(appInfo);
+ } catch (IncompatibleApiException exception) {
+ unbind(CarAppError.builder(mAppName).setCause(exception).build());
+ }
+ }
+ }
+
+ /** Binds to the app, if not bound already. */
+ @AnyThread
+ public void bind(Intent binderIntent) {
+ L.d(LogTags.APP_HOST, "Binding to %s with intent %s", this, binderIntent);
+ mMainHandler.removeMessages(MSG_UNBIND);
+ mMainHandler.removeMessages(MSG_REBIND);
+ final Intent originalIntent = IntentUtils.extractOriginalIntent(binderIntent);
+ mOriginalIntent = originalIntent;
+
+ switch (mBindingState) {
+ case UNBOUND:
+ setBindingState(BindingState.BINDING);
+
+ try {
+ // We bind to the app with host's capabilities, which allows the "while-in-use"
+ // permission capabilities (e.g. location) in the app's process for the duration of
+ // the binding.
+ // See go/watevra-nav-location for more information on the process capabilities.
+ if (mTemplateContext
+ .getApplicationContext()
+ .bindService(
+ binderIntent,
+ mServiceConnection,
+ Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES)) {
+ mTemplateContext
+ .getAnrHandler()
+ .callWithANRCheck(CarAppApi.BIND, (currentAnrToken) -> mANRToken = currentAnrToken);
+ } else {
+ failedToBind(null);
+ }
+ } catch (SecurityException e) {
+ L.e(LogTags.APP_HOST, e, "Cannot bind to the service.");
+ failedToBind(e);
+ }
+
+ return;
+ case BOUND:
+ dispatch(
+ CarContext.CAR_SERVICE,
+ NamedAppServiceCall.create(
+ CarAppApi.ON_NEW_INTENT,
+ (ICarApp carApp, ANRToken anrToken) ->
+ carApp.onNewIntent(
+ originalIntent,
+ new OnDoneCallbackStub(mTemplateContext, anrToken) {
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ super.onSuccess(response);
+ ThreadUtils.runOnMain(mCarAppBindingCallback::onNewIntentDispatched);
+ }
+ })));
+ return;
+ case BINDING:
+ L.d(LogTags.APP_HOST, "Already binding to %s", mAppName);
+ }
+ }
+
+ /** Dispatches the lifecycle call for the given {@code event} to the app. */
+ @AnyThread
+ public void dispatchAppLifecycleEvent(Event event) {
+ L.d(
+ LogTags.APP_HOST,
+ "Dispatching lifecycle event: %s, app: %s",
+ event,
+ mAppName.toShortString());
+
+ dispatch(
+ CarContext.CAR_SERVICE,
+ NamedAppServiceCall.create(
+ CarAppApi.DISPATCH_LIFECYCLE,
+ (ICarApp carApp, ANRToken anrToken) -> {
+ switch (event) {
+ case ON_START:
+ carApp.onAppStart(new OnDoneCallbackStub(mTemplateContext, anrToken));
+ return;
+ case ON_RESUME:
+ carApp.onAppResume(new OnDoneCallbackStub(mTemplateContext, anrToken));
+ return;
+ case ON_PAUSE:
+ carApp.onAppPause(new OnDoneCallbackStub(mTemplateContext, anrToken));
+ return;
+ case ON_STOP:
+ mMainHandler.removeMessages(MSG_UNBIND);
+ mMainHandler.removeMessages(MSG_REBIND);
+ mMainHandler.sendMessageDelayed(
+ mMainHandler.obtainMessage(MSG_UNBIND),
+ SECONDS.toMillis(mTemplateContext.getCarHostConfig().getAppUnbindSeconds()));
+ carApp.onAppStop(new OnDoneCallbackStub(mTemplateContext, anrToken));
+ return;
+ default:
+ // fall-through
+ }
+ throw new InvalidParameterException("Received unexpected lifecycle event: " + event);
+ }));
+ }
+
+ /**
+ * Dispatches the {@code call} to the appropriate manager.
+ *
+ * @param managerType one of the CarServiceType as defined in {@link CarContext}
+ * @param call the call to dispatch
+ */
+ @SuppressWarnings({"unchecked", "cast.unsafe"}) // Cannot check if instanceof ServiceT
+ @AnyThread
+ public <ServiceT extends IInterface> void dispatch(
+ String managerType, NamedAppServiceCall<ServiceT> call) {
+
+ ICarApp carApp = mCarApp;
+
+ if (mBindingState != BindingState.BOUND || carApp == null) {
+ mTemplateContext
+ .getErrorHandler()
+ .showError(
+ CarAppError.builder(mAppName)
+ .setDebugMessage(
+ "App is not bound when attempting to get service: "
+ + managerType
+ + ", call: "
+ + call)
+ .build());
+ return;
+ }
+
+ AppDispatcher appDispatcher = mTemplateContext.getAppDispatcher();
+
+ switch (managerType) {
+ case CarContext.APP_SERVICE:
+ if (mAppManager == null) {
+ dispatchGetManager(
+ appDispatcher,
+ managerType,
+ carApp,
+ manager -> {
+ mAppManager = (IAppManager) manager;
+ dispatchCall(appDispatcher, call, (ServiceT) mAppManager);
+ });
+ } else {
+ dispatchCall(appDispatcher, call, (ServiceT) mAppManager);
+ }
+ break;
+ case CarContext.NAVIGATION_SERVICE:
+ if (mNavigationManager == null) {
+ dispatchGetManager(
+ appDispatcher,
+ managerType,
+ carApp,
+ manager -> {
+ mNavigationManager = (INavigationManager) manager;
+ dispatchCall(appDispatcher, call, (ServiceT) mNavigationManager);
+ });
+ } else {
+ dispatchCall(appDispatcher, call, (ServiceT) mNavigationManager);
+ }
+ break;
+ case CarContext.CAR_SERVICE:
+ dispatchCall(appDispatcher, call, (ServiceT) carApp);
+ break;
+ default:
+ mTemplateContext
+ .getErrorHandler()
+ .showError(
+ CarAppError.builder(mAppName)
+ .setDebugMessage("No manager was found for type: " + managerType)
+ .build());
+ break;
+ }
+ }
+
+ /** Returns whether the app is currently bound to. */
+ @AnyThread
+ public boolean isBound() {
+ return mBindingState == BindingState.BOUND;
+ }
+
+ /** Returns whether the binder is in unbound state. */
+ @AnyThread
+ @VisibleForTesting
+ public boolean isUnbound() {
+ return mBindingState == BindingState.UNBOUND;
+ }
+
+ /** Returns the {@link ServiceConnection} instance used by this binding. */
+ @VisibleForTesting
+ public ServiceConnection getServiceConnection() {
+ return mServiceConnection;
+ }
+
+ /**
+ * Unbinds from the app.
+ *
+ * <p>Will not set an error screen.
+ *
+ * <p>If already unbound the call will be a no-op.
+ */
+ @AnyThread
+ public void unbind() {
+ L.d(LogTags.APP_HOST, "Unbinding from %s", this);
+ internalUnbind(null);
+ }
+
+ /**
+ * Unbinds from the app and sets an error screen.
+ *
+ * <p>If already unbound the call will be a no-op.
+ */
+ private void unbind(CarAppError error) {
+ L.d(LogTags.APP_HOST, "Unbinding from %s with error: %s", this, error);
+
+ internalUnbind(error);
+ }
+
+ private void internalUnbind(@Nullable CarAppError errorToShow) {
+ if (mBindingState != BindingState.UNBOUND) {
+ // Run on main thread so that we can unregister from listening for surface changes on
+ // the main thread before an error message is shown which could cause a onSurfaceChanged
+ // callback.
+ ThreadUtils.runOnMain(
+ () -> {
+ mOriginalIntent = null;
+ setBindingState(BindingState.UNBOUND);
+ if (errorToShow != null) {
+ mTemplateContext.getErrorHandler().showError(errorToShow);
+ }
+ resetAppServices();
+ mCarAppBindingCallback.onCarAppUnbound();
+ // Perform tear down logic first, then actually unbind.
+ mTemplateContext.getApplicationContext().unbindService(mServiceConnection);
+ });
+ }
+ }
+
+ private CarAppBinding(
+ TemplateContext templateContext,
+ ICarHost carHost,
+ CarAppBindingCallback carAppBindingCallback) {
+ mTemplateContext = templateContext;
+ mAppName = templateContext.getCarAppPackageInfo().getComponentName();
+ mCarHost = carHost;
+ mCarAppBindingCallback = carAppBindingCallback;
+ mTelemetryHandler = templateContext.getTelemetryHandler();
+ }
+
+ private void resetAppServices() {
+ mCarApp = null;
+ mAppManager = null;
+ mNavigationManager = null;
+ }
+
+ private void setBindingState(BindingState bindingState) {
+ if (mBindingState == bindingState) {
+ return;
+ }
+ BindingState previousState = mBindingState;
+ mBindingState = bindingState;
+ L.d(
+ LogTags.APP_HOST,
+ "Binding state changed from %s to %s for %s",
+ previousState,
+ bindingState,
+ mAppName.flattenToShortString());
+ }
+
+ /**
+ * Retrieves a car service manager from the app
+ *
+ * @param appDispatcher the dispatcher used for making the getManager call
+ * @param managerType one of the CarServiceType as defined in {@link CarContext}
+ * @param carApp the car app to retrieve the manager from
+ * @param callback the callback to trigger on receiving the result from the app
+ */
+ private void dispatchGetManager(
+ AppDispatcher appDispatcher, String managerType, ICarApp carApp, Consumer<Object> callback) {
+ appDispatcher.dispatch(
+ anrToken ->
+ carApp.getManager(
+ managerType,
+ new OnDoneCallbackStub(mTemplateContext, anrToken) {
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ super.onSuccess(checkNotNull(response));
+
+ try {
+ callback.accept(response.get());
+ } catch (BundlerException e) {
+ mTemplateContext
+ .getErrorHandler()
+ .showError(CarAppError.builder(mAppName).setCause(e).build());
+ return;
+ }
+ }
+ }),
+ CarAppApi.GET_MANAGER);
+ }
+
+ @SuppressWarnings("cast.unsafe") // Cannot check if instanceof ServiceT
+ private static <ServiceT extends IInterface> void dispatchCall(
+ AppDispatcher appDispatcher, NamedAppServiceCall<ServiceT> call, ServiceT serviceT) {
+ appDispatcher.dispatch(anrToken -> call.dispatch(serviceT, anrToken), call.getCarAppApi());
+ }
+
+ private final class ServiceConnectionImpl implements ServiceConnection {
+ private boolean mHasConnectedSinceLastBind;
+
+ @Override
+ public void onServiceConnected(ComponentName appName, IBinder service) {
+ L.d(LogTags.APP_HOST, "App service connected: %s", appName.flattenToShortString());
+ ANRToken token = mANRToken;
+ if (token != null) {
+ token.dismiss();
+ }
+ mHasConnectedSinceLastBind = true;
+
+ resetAppServices();
+ mCarApp = ICarApp.Stub.asInterface(service);
+ dispatchGetAppInfo(mCarApp);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName appName) {
+ L.d(LogTags.APP_HOST, "App service disconnected: %s", appName.flattenToShortString());
+
+ if (mHasConnectedSinceLastBind) {
+ mHasConnectedSinceLastBind = false;
+ setBindingState(BindingState.BINDING);
+ resetAppServices();
+ mTemplateContext.getEventManager().dispatchEvent(APP_DISCONNECTED);
+ } else {
+ unbind(
+ CarAppError.builder(appName)
+ .setDebugMessage("The app has crashed multiple times")
+ .build());
+ }
+ }
+
+ @Override
+ public void onBindingDied(ComponentName appName) {
+ L.d(LogTags.APP_HOST, "App binding died: %s", appName.flattenToShortString());
+
+ mMainHandler.removeMessages(MSG_REBIND);
+
+ setBindingState(BindingState.UNBOUND);
+ resetAppServices();
+ mTemplateContext.getEventManager().dispatchEvent(APP_DISCONNECTED);
+
+ mMainHandler.sendMessageDelayed(mMainHandler.obtainMessage(MSG_REBIND), 500);
+ }
+
+ @Override
+ public void onNullBinding(ComponentName name) {
+ unbind(CarAppError.builder(mAppName).setDebugMessage("Null binding from app").build());
+ }
+
+ private void dispatchGetAppInfo(ICarApp carApp) {
+ mTemplateContext
+ .getAppDispatcher()
+ .dispatch(
+ anrToken -> sendAppInfoIPC(carApp, anrToken),
+ CarAppBinding.this::unbind,
+ CarAppApi.GET_APP_VERSION);
+ }
+
+ private void sendAppInfoIPC(ICarApp carApp, ANRToken anrToken) throws RemoteException {
+ carApp.getAppInfo(
+ new OnDoneCallbackStub(mTemplateContext, anrToken) {
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ super.onSuccess(checkNotNull(response));
+ CarHostConfig hostConfig = mTemplateContext.getCarHostConfig();
+ try {
+ AppInfo appInfo = (AppInfo) response.get();
+ mTelemetryHandler.logCarAppTelemetry(
+ TelemetryEvent.newBuilder(UiAction.CLIENT_SDK_VERSION, mAppName)
+ .setCarAppSdkVersion(appInfo.getLibraryDisplayVersion()));
+ dispatchOnHandshakeCompleted(carApp, hostConfig.updateNegotiatedApi(appInfo));
+ mAppInfo = appInfo;
+
+ } catch (BundlerException e) {
+ unbind(CarAppError.builder(mAppName).setCause(e).build());
+ } catch (IncompatibleApiException e) {
+ unbind(
+ CarAppError.builder(mAppName)
+ .setType(CarAppError.Type.INCOMPATIBLE_CLIENT_VERSION)
+ .setCause(e)
+ .build());
+ }
+ }
+ });
+ }
+
+ @SuppressWarnings("RestrictTo")
+ private void dispatchOnHandshakeCompleted(ICarApp carApp, int negotiatedApiLevel) {
+ HandshakeInfo handshakeInfo =
+ new HandshakeInfo(mTemplateContext.getPackageName(), negotiatedApiLevel);
+ mTemplateContext
+ .getAppDispatcher()
+ .dispatch(
+ anrToken ->
+ carApp.onHandshakeCompleted(
+ Bundleable.create(handshakeInfo),
+ new OnDoneCallbackStub(mTemplateContext, anrToken) {
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ super.onSuccess(response);
+ dispatchOnAppCreate(carApp);
+ }
+ }),
+ CarAppBinding.this::unbind,
+ CarAppApi.ON_HANDSHAKE_COMPLETED);
+ }
+
+ private void dispatchOnAppCreate(ICarApp carApp) {
+ mTemplateContext
+ .getAppDispatcher()
+ .dispatch(
+ anrToken ->
+ carApp.onAppCreate(
+ mCarHost,
+ checkNotNull(mOriginalIntent),
+ mTemplateContext.getResources().getConfiguration(),
+ new OnDoneCallbackStub(mTemplateContext, anrToken) {
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ super.onSuccess(response);
+ setBindingState(BindingState.BOUND);
+ ThreadUtils.runOnMain(mCarAppBindingCallback::onCarAppBound);
+ }
+
+ @Override
+ public void onFailure(Bundleable failureResponse) {
+ super.onFailure(failureResponse);
+ L.d(LogTags.APP_HOST, "OnAppCreate Failure");
+ internalUnbind(null);
+ }
+ }),
+ CarAppBinding.this::unbind,
+ CarAppApi.ON_APP_CREATE);
+ }
+ }
+
+ /** A {@link Handler.Callback} used to implement unbinding. */
+ private class HandlerCallback implements Handler.Callback {
+ @Override
+ public boolean handleMessage(Message msg) {
+ if (msg.what == MSG_UNBIND) {
+ if (mTemplateContext.getCarAppPackageInfo().isNavigationApp()) {
+ L.d(LogTags.APP_HOST, "Not unbinding due to the app being a navigation app");
+ return true;
+ }
+ unbind();
+ return true;
+ } else if (msg.what == MSG_REBIND) {
+ bind(new Intent().setComponent(mAppName));
+ return true;
+ }
+
+ L.w(LogTags.APP_HOST, "Unknown message: %s", msg);
+ return false;
+ }
+ }
+
+ /** Updates the internal state and shows an error. */
+ private void failedToBind(@Nullable Throwable cause) {
+ // Set the state to unbound as the binding was unsuccessful.
+ setBindingState(BindingState.UNBOUND);
+
+ CarAppError.Builder builder =
+ CarAppError.builder(mAppName).setDebugMessage("Failed to bind to " + mAppName);
+
+ if (cause != null) {
+ builder.setCause(cause);
+ }
+
+ mTemplateContext.getErrorHandler().showError(builder.build());
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBindingCallback.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBindingCallback.java
new file mode 100644
index 0000000..e1f642a
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppBindingCallback.java
@@ -0,0 +1,28 @@
+/*
+ * 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.internal;
+
+/** Provides callbacks for binding-related interaction. */
+public interface CarAppBindingCallback {
+ /** Notifies when the app is bound. */
+ void onCarAppBound();
+
+ /** Notifies that bind was called, when already bound, and onNewIntent was dispatched. */
+ void onNewIntentDispatched();
+
+ /** Notifies when the app is no longer bound. */
+ void onCarAppUnbound();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppPackageInfoImpl.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppPackageInfoImpl.java
new file mode 100644
index 0000000..661cf23
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/CarAppPackageInfoImpl.java
@@ -0,0 +1,113 @@
+/*
+ * 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.internal;
+
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.android.car.libraries.apphost.common.AppIconLoader;
+import com.android.car.libraries.apphost.common.CarAppColors;
+import com.android.car.libraries.apphost.common.CarAppPackageInfo;
+import com.android.car.libraries.apphost.common.CarColorUtils;
+import com.android.car.libraries.apphost.common.HostResourceIds;
+import java.util.Objects;
+
+/** Provides package information of a 3p car app built using AndroidX Car SDK (go/watevra). */
+public class CarAppPackageInfoImpl implements CarAppPackageInfo {
+ private final Context mContext;
+ private final ComponentName mComponentName;
+ private final boolean mIsNavigationApp;
+ private final AppIconLoader mAppIconLoader;
+ private final HostResourceIds mHostResourceIds;
+
+ private boolean mIsLoaded;
+ @Nullable private CarAppColors mCarAppColors;
+
+ /**
+ * Creates a {@link CarAppPackageInfoImpl} for the application identified by the given {@link
+ * ComponentName}.
+ *
+ * @param context Host context, used to retrieve host resources and configurations
+ * @param componentName Identifier of the car app this instance will provide metadata for
+ * @param isNavigationApp Whether the given car app is a navigation app or not
+ * @param hostResourceIds Host resources, used to retrieve default colors to use in case the app
+ * doesn't provide their own
+ */
+ public static CarAppPackageInfo create(
+ @NonNull Context context,
+ @NonNull ComponentName componentName,
+ boolean isNavigationApp,
+ @NonNull HostResourceIds hostResourceIds,
+ @NonNull AppIconLoader appIconLoader) {
+ return new CarAppPackageInfoImpl(
+ context, componentName, isNavigationApp, hostResourceIds, appIconLoader);
+ }
+
+ @Override
+ @NonNull
+ public ComponentName getComponentName() {
+ return mComponentName;
+ }
+
+ @NonNull
+ @Override
+ public CarAppColors getAppColors() {
+ ensureLoaded();
+ return Objects.requireNonNull(mCarAppColors);
+ }
+
+ @Override
+ public boolean isNavigationApp() {
+ return mIsNavigationApp;
+ }
+
+ @Override
+ @NonNull
+ public Drawable getRoundAppIcon() {
+ return mAppIconLoader.getRoundAppIcon(mContext, mComponentName);
+ }
+
+ @Override
+ public String toString() {
+ return "[" + mComponentName.flattenToShortString() + ", isNav: " + mIsNavigationApp + "]";
+ }
+
+ @SuppressLint("ResourceType")
+ private void ensureLoaded() {
+ if (mIsLoaded) {
+ return;
+ }
+
+ mCarAppColors = CarColorUtils.resolveAppColor(mContext, mComponentName, mHostResourceIds);
+ mIsLoaded = true;
+ }
+
+ private CarAppPackageInfoImpl(
+ @NonNull Context context,
+ @NonNull ComponentName componentName,
+ boolean isNavigationApp,
+ @NonNull HostResourceIds hostResourceIds,
+ @NonNull AppIconLoader appIconLoader) {
+ mContext = context;
+ mComponentName = componentName;
+ mIsNavigationApp = isNavigationApp;
+ mHostResourceIds = hostResourceIds;
+ mAppIconLoader = appIconLoader;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/LocationMediatorImpl.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/LocationMediatorImpl.java
new file mode 100644
index 0000000..10ce9db
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/internal/LocationMediatorImpl.java
@@ -0,0 +1,113 @@
+/*
+ * 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.internal;
+
+import android.location.Location;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarLocation;
+import androidx.car.app.model.Place;
+import com.android.car.libraries.apphost.common.EventManager;
+import com.android.car.libraries.apphost.common.EventManager.EventType;
+import com.android.car.libraries.apphost.common.LocationMediator;
+import com.android.car.libraries.apphost.common.ThreadUtils;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * An implementation of {@link LocationMediator}.
+ *
+ * <p>There's only one set of places available at any given time, so the last writer wins. This
+ * class is not meant to be used with multiple publishers (e.g. shared between multiple apps) so
+ * that is just fine.
+ *
+ * <p>This class is not safe for concurrent access.
+ */
+public class LocationMediatorImpl implements LocationMediator {
+ /** Interface for requesting start and stop of location updates from an app. */
+ public interface AppLocationUpdateRequester {
+ /** Sets whether to get location updates from an app. */
+ void enableLocationUpdates(boolean enabled);
+ }
+
+ @Nullable private CarLocation mCameraAnchor;
+ private List<Place> mCurrentPlaces = ImmutableList.of();
+ private final List<AppLocationListener> mAppLocationListeners = new ArrayList<>();
+ private final EventManager mEventManager;
+ private final AppLocationUpdateRequester mLocationUpdateRequester;
+
+ /** Returns an instance of a {@link LocationMediator}. */
+ public static LocationMediator create(
+ EventManager eventManager, AppLocationUpdateRequester locationUpdateRequester) {
+ return new LocationMediatorImpl(eventManager, locationUpdateRequester);
+ }
+
+ @Override
+ public List<Place> getCurrentPlaces() {
+ return mCurrentPlaces;
+ }
+
+ @Override
+ public void setCurrentPlaces(List<Place> places) {
+ ThreadUtils.ensureMainThread();
+
+ if (mCurrentPlaces.equals(places)) {
+ return;
+ }
+ mCurrentPlaces = places;
+ mEventManager.dispatchEvent(EventType.PLACE_LIST);
+ }
+
+ @Override
+ @Nullable
+ public CarLocation getCameraAnchor() {
+ return mCameraAnchor;
+ }
+
+ @Override
+ public void setCameraAnchor(@Nullable CarLocation cameraAnchor) {
+ mCameraAnchor = cameraAnchor;
+ }
+
+ @Override
+ public void addAppLocationListener(AppLocationListener listener) {
+ if (mAppLocationListeners.isEmpty()) {
+ mLocationUpdateRequester.enableLocationUpdates(true);
+ }
+ mAppLocationListeners.add(listener);
+ }
+
+ @Override
+ public void removeAppLocationListener(AppLocationListener listener) {
+ mAppLocationListeners.remove(listener);
+ if (mAppLocationListeners.isEmpty()) {
+ mLocationUpdateRequester.enableLocationUpdates(false);
+ }
+ }
+
+ @Override
+ public void setAppLocation(Location location) {
+ for (AppLocationListener listener : mAppLocationListeners) {
+ listener.onAppLocationChanged(location);
+ }
+ }
+
+ private LocationMediatorImpl(
+ EventManager eventManager, AppLocationUpdateRequester locationUpdateRequester) {
+ mEventManager = eventManager;
+ mLocationUpdateRequester = locationUpdateRequester;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/NullUtils.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/NullUtils.java
new file mode 100644
index 0000000..b878286
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/NullUtils.java
@@ -0,0 +1,98 @@
+/*
+ * 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.lang;
+
+import static com.android.car.libraries.apphost.logging.L.buildMessage;
+
+import androidx.annotation.Nullable;
+import androidx.core.util.Supplier;
+
+/**
+ * Utility methods for handling {@code null}able fields and methods.
+ *
+ * <p>These methods should be statically imported and <b>not</b> qualified by class name.
+ */
+public class NullUtils {
+ /**
+ * Returns a {@link Denullerator} with an initial value and type corresponding to the passed
+ * parameter.
+ */
+ public static <T extends Object> Denullerator<T> ifNonNull(@Nullable T reference) {
+ return new Denullerator<>(reference);
+ }
+
+ /**
+ * A reference that can store {@code null} values but from which {@code null} values can never
+ * be retrieved.
+ *
+ * <p>Note that the generic parameter must extend Object explicitly to ensure that the generic
+ * type itself does not match something @Nullable. See
+ * http://go/nullness_troubleshooting#issues-with-type-parameter-annotations
+ *
+ * @param <T> target class
+ */
+ public static class Denullerator<T extends Object> {
+ @Nullable private T mReference;
+
+ /**
+ * New Denullerators should only be created using {@link NullUtils#ifNonNull(Object)} above.
+ */
+ private Denullerator(@Nullable T reference) {
+ mReference = reference;
+ }
+
+ /** Returns a denullerator of a reference value. */
+ public Denullerator<T> otherwiseIfNonNull(@Nullable T reference) {
+ if (mReference == null) {
+ mReference = reference;
+ }
+ return this;
+ }
+
+ /** Returns a denullerators of a reference value supplier. */
+ public Denullerator<T> otherwiseIfNonNull(Supplier<@PolyNull T> referenceSupplier) {
+ if (mReference == null) {
+ mReference = referenceSupplier.get();
+ }
+ return this;
+ }
+
+ /** Return the value if it's not non-null. */
+ public T otherwise(T reference) {
+ return otherwiseIfNonNull(reference).otherwiseThrow();
+ }
+
+ /** Return the value if it's not non-null. */
+ public T otherwise(Supplier<T> referenceSupplier) {
+ return otherwiseIfNonNull(referenceSupplier).otherwiseThrow();
+ }
+
+ /** Returns an exception that values are not non-null */
+ public T otherwiseThrow() {
+ return otherwiseThrow("None of the supplied values were non-null!");
+ }
+
+ /** Returns the reference if it's not non-null. */
+ public T otherwiseThrow(String msg, Object... msgArgs) {
+ if (mReference == null) {
+ throw new NullPointerException(buildMessage(msg, msgArgs));
+ }
+ return mReference;
+ }
+ }
+
+ private NullUtils() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/PolyNull.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/PolyNull.java
new file mode 100644
index 0000000..2169c21
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/lang/PolyNull.java
@@ -0,0 +1,56 @@
+/*
+ * 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.lang;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * This is an annotation stub to avoid dependencies on annotations that aren't in the Android
+ * platform source tree.
+ */
+@Target({
+ ElementType.ANNOTATION_TYPE,
+ ElementType.CONSTRUCTOR,
+ ElementType.FIELD,
+ ElementType.LOCAL_VARIABLE,
+ ElementType.METHOD,
+ ElementType.PACKAGE,
+ ElementType.PARAMETER,
+ ElementType.TYPE,
+ ElementType.TYPE_PARAMETER,
+ ElementType.TYPE_USE
+})
+@Retention(RetentionPolicy.SOURCE)
+public @interface PolyNull {
+ /** This is an enum stub to avoid dependencies. */
+ enum MigrationStatus {
+ IGNORE,
+ WARN,
+ STRICT
+ }
+
+ // These fields maintain API compatibility with annotations that expect arguments.
+ String[] value() default {};
+
+ boolean result() default false;
+
+ String[] expression() default "";
+
+ MigrationStatus status() default MigrationStatus.IGNORE;
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApi.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApi.java
new file mode 100644
index 0000000..5198293
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApi.java
@@ -0,0 +1,59 @@
+/*
+ * 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.logging;
+
+/** Each enum represents one of the Car App Library's possible host to client APIs. */
+// TODO(b/171817245): Remove LINT.IFTT in copybara
+// LINT.IfChange
+public enum CarAppApi {
+ UNKNOWN_API,
+ GET_APP_VERSION,
+ ON_HANDSHAKE_COMPLETED,
+ GET_MANAGER,
+ GET_TEMPLATE,
+ ON_APP_CREATE,
+ DISPATCH_LIFECYCLE,
+ ON_NEW_INTENT,
+ ON_CONFIGURATION_CHANGED,
+ ON_SURFACE_AVAILABLE,
+ ON_SURFACE_DESTROYED,
+ ON_VISIBLE_AREA_CHANGED,
+ ON_STABLE_AREA_CHANGED,
+ ON_CLICK,
+ ON_SELECTED,
+ ON_SEARCH_TEXT_CHANGED,
+ ON_SEARCH_SUBMITTED,
+ ON_NAVIGATE,
+ STOP_NAVIGATION,
+ ON_RECORDING_STARTED,
+ ON_RECORDING_STOPPED,
+ ON_ITEM_VISIBILITY_CHANGED,
+ ON_CHECKED_CHANGED,
+ ON_BACK_PRESSED,
+ BIND,
+ ON_INPUT_SUBMITTED,
+ ON_INPUT_TEXT_CHANGED,
+ ON_CARHARDWARE_RESULT,
+ ON_PAN_MODE_CHANGED,
+ START_LOCATION_UPDATES,
+ STOP_LOCATION_UPDATES,
+}
+// LINT.ThenChange(//depot/google3/java/com/google/android/apps/auto/components/apphost/internal/\
+// TelemetryHandlerImpl.java,
+// //depot/google3/java/com/google/android/apps/automotive/templates/host/di/logging/\
+// ClearcutTelemetryHandler.java,
+// //depot/google3/logs/proto/wireless/android/automotive/templates/host/\
+// android_automotive_templates_host_info.proto)
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApiErrorType.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApiErrorType.java
new file mode 100644
index 0000000..92d09e3
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/CarAppApiErrorType.java
@@ -0,0 +1,35 @@
+/*
+ * 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.logging;
+
+/** Different errors that may happen due to a Car App Library IPC. */
+// LINT.IfChange
+public enum CarAppApiErrorType {
+ UNKNOWN_ERROR,
+ BUNDLER_EXCEPTION,
+ ILLEGAL_STATE_EXCEPTION,
+ INVALID_PARAMETER_EXCEPTION,
+ SECURITY_EXCEPTION,
+ RUNTIME_EXCEPTION,
+ REMOTE_EXCEPTION,
+ ANR
+}
+// LINT.ThenChange(//depot/google3/java/com/google/android/apps/auto/components/apphost/internal/\
+// TelemetryHandlerImpl.java,
+// //depot/google3/java/com/google/android/apps/automotive/templates/host/di/logging/\
+// ClearcutTelemetryLogger.java,
+// //depot/google3/logs/proto/wireless/android/automotive/templates/host/\
+// android_automotive_templates_host_info.proto)
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/ContentLimitQuery.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/ContentLimitQuery.java
new file mode 100644
index 0000000..ea64917
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/ContentLimitQuery.java
@@ -0,0 +1,62 @@
+/*
+ * 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.logging;
+
+import com.google.auto.value.AutoValue;
+
+/** Internal representation of the content limit queried by car app */
+@AutoValue
+public abstract class ContentLimitQuery {
+
+ /** Returns the content limit type */
+ public abstract int getContentLimitType();
+
+ /** Returns the content limit value */
+ public abstract int getContentLimitValue();
+
+ /**
+ * Returns a new builder of {@link ContentLimitQuery} set up with the information from this event.
+ */
+ public static ContentLimitQuery.Builder newBuilder() {
+ return new AutoValue_ContentLimitQuery.Builder();
+ }
+
+ /** ContentLimit builder. */
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ /** Sets the content limits {@code type}. */
+ public abstract Builder setContentLimitType(int type);
+
+ /** Sets the content limits {@code value}. */
+ public abstract Builder setContentLimitValue(int value);
+
+ /** Builds a {@link ContentLimitQuery} from this builder. */
+ public ContentLimitQuery build() {
+ return autoBuild();
+ }
+
+ abstract ContentLimitQuery autoBuild();
+ }
+
+ /** Returns a {@link ContentLimitQuery} with given {@code type} and {@code value}. */
+ public static ContentLimitQuery getContentLimitQuery(int type, int value) {
+ return ContentLimitQuery.newBuilder()
+ .setContentLimitValue(value)
+ .setContentLimitType(type)
+ .build();
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/L.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/L.java
new file mode 100644
index 0000000..a93469d
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/L.java
@@ -0,0 +1,532 @@
+/*
+ * 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.logging;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+import java.util.Arrays;
+import java.util.IllegalFormatException;
+import java.util.Locale;
+import java.util.function.Supplier;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Helper class for logging. */
+public final class L {
+ private static final String STRING_MEANING_NULL = "null";
+
+ /** Builds a log message from a message format string and its arguments. */
+ public static String buildMessage(@Nullable String message, @Nullable Object... args) {
+ // If the message is null, ignore the args and return "null";
+ if (message == null) {
+ return STRING_MEANING_NULL;
+ }
+
+ // else if the args are null or 0-length, return message
+ if (args == null || args.length == 0) {
+ try {
+ return String.format(Locale.US, message);
+ } catch (IllegalFormatException ex) {
+ return message;
+ }
+ }
+
+ // Use deepToString to get a more useful representation of any arrays in args
+ for (int i = 0; i < args.length; i++) {
+ if (args[i] != null && args[i].getClass().isArray()) {
+ // Wrap in an array, deepToString, then remove the extra [] from the wrapper. This
+ // allows handling all array types rather than having separate branches for all
+ // primitive array types plus Object[].
+ String string = Arrays.deepToString(new Object[] {args[i]});
+ // Strip the outer [] from the wrapper array.
+ args[i] = string.substring(1, string.length() - 1);
+ }
+ }
+
+ // else try formatting the string.
+ try {
+ return String.format(Locale.US, message, args);
+ } catch (IllegalFormatException ex) {
+ return message + Arrays.deepToString(args);
+ }
+ }
+
+ /**
+ * Log a verbose message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void v(String tag, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.VERBOSE)) {
+ Log.v(tag, message);
+ }
+ }
+
+ /**
+ * Log a verbose message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message a supplier of the message to log. This will only be executed if the log level
+ * for the given tag is enabled.
+ */
+ public static void v(String tag, @NonNull Supplier<String> message) {
+ if (Log.isLoggable(tag, Log.VERBOSE)) {
+ Log.v(tag, message.get());
+ }
+ }
+
+ /**
+ * Log a verbose message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void v(
+ String tag, @NonNull @FormatString String message, @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.VERBOSE)) {
+ Log.v(tag, buildMessage(message, args));
+ }
+ }
+
+ /**
+ * Log a verbose message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void v(String tag, @Nullable Throwable th, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.VERBOSE)) {
+ Log.v(tag, message, th);
+ }
+ }
+
+ /**
+ * Log a verbose message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void v(
+ String tag,
+ @Nullable Throwable th,
+ @NonNull @FormatString String message,
+ @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.VERBOSE)) {
+ Log.v(tag, buildMessage(message, args), th);
+ }
+ }
+
+ /**
+ * Log a debug message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void d(String tag, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, message);
+ }
+ }
+
+ /**
+ * Log a debug message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message a supplier of the message to log. This will only be executed if the log level
+ * for the given tag is enabled.
+ */
+ public static void d(String tag, @NonNull Supplier<String> message) {
+ if (Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, message.get());
+ }
+ }
+
+ /**
+ * Log a debug message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void d(
+ String tag, @NonNull @FormatString String message, @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, buildMessage(message, args));
+ }
+ }
+
+ /**
+ * Log a debug message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void d(String tag, @Nullable Throwable th, @NonNull @FormatString String message) {
+
+ if (Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, message, th);
+ }
+ }
+
+ /**
+ * Log a debug message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void d(
+ String tag,
+ @Nullable Throwable th,
+ @NonNull @FormatString String message,
+ @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, buildMessage(message, args), th);
+ }
+ }
+
+ /**
+ * Log an info message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void i(String tag, @NonNull @FormatString String message) {
+
+ if (Log.isLoggable(tag, Log.INFO)) {
+ Log.i(tag, message);
+ }
+ }
+
+ /**
+ * Log an info message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void i(
+ String tag, @NonNull @FormatString String message, @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.INFO)) {
+ Log.i(tag, buildMessage(message, args));
+ }
+ }
+
+ /**
+ * Log an info message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message a supplier of the message to log. This will only be executed if the log level
+ * for the given tag is enabled.
+ */
+ public static void i(String tag, @NonNull Supplier<String> message) {
+ if (Log.isLoggable(tag, Log.INFO)) {
+ Log.i(tag, message.get());
+ }
+ }
+
+ /**
+ * Log an info message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void i(String tag, @Nullable Throwable th, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.INFO)) {
+ Log.i(tag, message, th);
+ }
+ }
+
+ /**
+ * Log an info message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void i(
+ String tag,
+ @Nullable Throwable th,
+ @NonNull @FormatString String message,
+ @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.INFO)) {
+ Log.i(tag, buildMessage(message, args), th);
+ }
+ }
+
+ /**
+ * Log a warning message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void w(String tag, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.WARN)) {
+ Log.w(tag, message);
+ }
+ }
+
+ /**
+ * Log a warning message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message a supplier of the message to log. This will only be executed if the log level
+ * for the given tag is enabled.
+ */
+ public static void w(String tag, @NonNull Supplier<String> message) {
+ if (Log.isLoggable(tag, Log.WARN)) {
+ Log.w(tag, message.get());
+ }
+ }
+
+ /**
+ * Log a warning message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log.
+ * @param message a supplier of the message to log. This will only be executed if the log level
+ * for the given tag is enabled.
+ */
+ public static void w(String tag, @Nullable Throwable th, @NonNull Supplier<String> message) {
+ if (Log.isLoggable(tag, Log.WARN)) {
+ Log.w(tag, message.get(), th);
+ }
+ }
+
+ /**
+ * Log a warning message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void w(
+ String tag, @NonNull @FormatString String message, @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.WARN)) {
+ Log.w(tag, buildMessage(message, args));
+ }
+ }
+
+ /**
+ * Log a warning message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void w(String tag, @Nullable Throwable th, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.WARN)) {
+ Log.w(tag, message, th);
+ }
+ }
+
+ /**
+ * Log a warning message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void w(
+ String tag,
+ @Nullable Throwable th,
+ @NonNull @FormatString String message,
+ @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.WARN)) {
+ Log.w(tag, buildMessage(message, args), th);
+ }
+ }
+
+ /**
+ * Log an error message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void e(String tag, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.ERROR)) {
+ Log.e(tag, message);
+ }
+ }
+
+ /**
+ * Log an error message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void e(
+ String tag, @NonNull @FormatString String message, @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.ERROR)) {
+ Log.e(tag, buildMessage(message, args));
+ }
+ }
+
+ /**
+ * Log an error message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ */
+ @FormatMethod
+ public static void e(String tag, @Nullable Throwable th, @NonNull @FormatString String message) {
+ if (Log.isLoggable(tag, Log.ERROR)) {
+ Log.e(tag, message, th);
+ }
+ }
+
+ /**
+ * Log an error message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param message a supplier of the message to log. This will only be executed if the log level
+ * for the given tag is enabled.
+ */
+ public static void e(String tag, @NonNull Supplier<String> message) {
+ if (Log.isLoggable(tag, Log.ERROR)) {
+ Log.e(tag, message.get());
+ }
+ }
+
+ /**
+ * Log an error message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log.
+ * @param message a supplier of the message to log. This will only be executed if the log level
+ * for the given tag is enabled.
+ */
+ public static void e(String tag, @Nullable Throwable th, @NonNull Supplier<String> message) {
+ if (Log.isLoggable(tag, Log.ERROR)) {
+ Log.e(tag, message.get(), th);
+ }
+ }
+
+ /**
+ * Log an error message.
+ *
+ * @param tag the tag shouldn't be more than 23 characters as {@link Log#isLoggable(String, int)}
+ * has this restriction.
+ * @param th a throwable to log.
+ * @param message the string message to log. This can also be a string format that's recognized by
+ * {@link String#format(String, Object...)}. e.g. "%s did something to %s, and %d happened as
+ * a result".
+ * @param args the formatting args for the previous string.
+ */
+ @FormatMethod
+ public static void e(
+ String tag,
+ @Nullable Throwable th,
+ @NonNull @FormatString String message,
+ @Nullable Object... args) {
+ if (Log.isLoggable(tag, Log.ERROR)) {
+ Log.e(tag, buildMessage(message, args), th);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/LogTags.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/LogTags.java
new file mode 100644
index 0000000..ad48ed3
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/LogTags.java
@@ -0,0 +1,51 @@
+/*
+ * 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.logging;
+
+/**
+ * Declares the log tags to use in the app host package.
+ *
+ * <p>These tags are defined at a higher logical and component level, rather than on a strict
+ * per-class basis.
+ *
+ * <p><strong>IMPORTANT</strong>: do not use per-class tags, since those are often way too granular,
+ * hard to manage, and an inferior choice in every way. If you need finer-granularity tags than
+ * those here, consider adding a new one.
+ */
+public abstract class LogTags {
+ /** General purpose tag used for most components. */
+ public static final String APP_HOST = "CarApp.H";
+
+ /** Tag for code related to constraint host. */
+ public static final String CONSTRAINT = APP_HOST + ".Con";
+
+ /** Tag for code related to driver distraction handling. */
+ public static final String DISTRACTION = APP_HOST + ".Dis";
+
+ /** Tag for code related to template handling. */
+ public static final String TEMPLATE = APP_HOST + ".Tem";
+
+ /** Tag for navigation specific host code. */
+ public static final String NAVIGATION = APP_HOST + ".Nav";
+
+ /** Tag for cluster specific host code. */
+ public static final String CLUSTER = APP_HOST + ".Clu";
+
+ /** Tag for renderer service (automotive) specific host code. */
+ public static final String SERVICE = APP_HOST + ".Ser";
+
+ private LogTags() {}
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/StatusReporter.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/StatusReporter.java
new file mode 100644
index 0000000..ab19b69
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/StatusReporter.java
@@ -0,0 +1,37 @@
+/*
+ * 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.logging;
+
+import java.io.PrintWriter;
+
+/** An interface for a component that can contribute status to a bug report. */
+public interface StatusReporter {
+ /** Specifies how to handle PII in a status report. */
+ enum Pii {
+ /** Omit PII from the bug report. */
+ HIDE,
+ /** Show PII in the bug report. */
+ SHOW
+ }
+
+ /**
+ * Writes the status of this component to a bug report.
+ *
+ * @param pw A {@link PrintWriter} to which to write the status.
+ * @param piiHandling How to handle PII in the report.
+ */
+ void reportStatus(PrintWriter pw, Pii piiHandling);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryEvent.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryEvent.java
new file mode 100644
index 0000000..d5deb3a
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryEvent.java
@@ -0,0 +1,165 @@
+/*
+ * 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.logging;
+
+import android.content.ComponentName;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+
+/** Internal representation of a telemetry event. */
+@AutoValue
+public abstract class TelemetryEvent {
+
+ /** Types of actions to be reported */
+ // LINT.IfChange
+ public enum UiAction {
+ APP_START,
+ APP_RUNTIME,
+
+ CAR_APP_API_SUCCESS,
+ CAR_APP_API_FAILURE,
+
+ CAR_APPS_AVAILABLE,
+
+ CLIENT_SDK_VERSION,
+ HOST_SDK_VERSION,
+
+ TEMPLATE_FLOW_LIMIT_EXCEEDED,
+ TEMPLATE_FLOW_INVALID_BACK,
+
+ NAVIGATION_STARTED,
+ NAVIGATION_TRIP_UPDATED,
+ NAVIGATION_ENDED,
+
+ PAN,
+ ROTARY_PAN,
+ FLING,
+ ZOOM,
+
+ ROW_CLICKED,
+ ACTION_STRIP_FAB_CLICKED,
+ ACTION_BUTTON_CLICKED,
+
+ LIST_SIZE,
+ ACTION_STRIP_SIZE,
+ GRID_ITEM_LIST_SIZE,
+
+ ACTION_STRIP_SHOW,
+ ACTION_STRIP_HIDE,
+
+ CONTENT_LIMIT_QUERY,
+
+ HOST_FAILURE_CLUSTER_ICON,
+
+ MINIMIZED_STATE,
+
+ SPEEDBUMPED,
+
+ COLOR_CONTRAST_CHECK_PASSED,
+ COLOR_CONTRAST_CHECK_FAILED,
+ }
+
+ /** Returns the {@link UiAction} that represents the type of action associated with this event. */
+ public abstract UiAction getAction();
+
+ /** Returns the version of the app SDK. */
+ public abstract Optional<String> getCarAppSdkVersion();
+
+ /** Returns the duration of the event, in milliseconds. */
+ public abstract Optional<Long> getDurationMs();
+
+ /** Returns the {@link CarAppApi} associated with the event. */
+ public abstract Optional<CarAppApi> getCarAppApi();
+
+ /** Returns the {@link ComponentName} for the app the event is coming from. */
+ public abstract Optional<ComponentName> getComponentName();
+
+ /** Returns the {@link CarAppApiErrorType} if the event is an error. */
+ public abstract Optional<CarAppApiErrorType> getErrorType();
+
+ /** Returns the position of the event */
+ public abstract Optional<Integer> getPosition();
+
+ /** Returns the count of the loaded item */
+ public abstract Optional<Integer> getItemsLoadedCount();
+
+ /** Returns a {@link ContentLimitQuery} that is used in the car app. */
+ public abstract Optional<ContentLimitQuery> getCarAppContentLimitQuery();
+
+ /** Returns the name of the template used for this event. */
+ public abstract Optional<String> getTemplateClassName();
+
+ /**
+ * Returns a new builder of {@link TelemetryEvent} set up with the given {@link UiAction}, and the
+ * provided {@link ComponentName} set.
+ */
+ public static Builder newBuilder(UiAction action, ComponentName appName) {
+ return newBuilder(action).setComponentName(appName);
+ }
+
+ /** Returns a new builder of {@link TelemetryEvent} set up with the given {@link UiAction} */
+ public static Builder newBuilder(UiAction action) {
+ return new AutoValue_TelemetryEvent.Builder().setAction(action);
+ }
+
+ /** UiLogEvent builder. */
+ @AutoValue.Builder
+ public abstract static class Builder {
+ /** Sets the {@link UiAction} that represents the type of action associated with this event. */
+ public abstract Builder setAction(UiAction action);
+
+ /** Sets the version of the app SDK. */
+ public abstract Builder setCarAppSdkVersion(String carAppSdkVersion);
+
+ /** Sets the duration of the event, in milliseconds. */
+ public abstract Builder setDurationMs(long durationMillis);
+
+ /** Sets the {@link CarAppApi} associated with the event. */
+ public abstract Builder setCarAppApi(CarAppApi carAppApi);
+
+ /** Sets the {@link ComponentName} for the app the event is coming from. */
+ public abstract Builder setComponentName(ComponentName componentName);
+
+ /** Sets the {@link CarAppApiErrorType} if the event is an error. */
+ public abstract Builder setErrorType(CarAppApiErrorType errorType);
+
+ /** Sets the position of the event */
+ public abstract Builder setPosition(int position);
+
+ /** Sets the count of the loaded item */
+ public abstract Builder setItemsLoadedCount(int position);
+
+ /** Sets the {@link ContentLimitQuery} that is used in the car app. */
+ public abstract Builder setCarAppContentLimitQuery(ContentLimitQuery constraints);
+
+ /** Sets the class name of the template */
+ public abstract Builder setTemplateClassName(String className);
+
+ /** Builds a {@link TelemetryEvent} from this builder. */
+ public TelemetryEvent build() {
+ return autoBuild();
+ }
+
+ /** Non-visible builder method for AutoValue to implement. */
+ abstract TelemetryEvent autoBuild();
+ }
+ // LINT.ThenChange(//depot/google3/java/com/google/android/apps/auto/components/apphost/\
+ // internal/TelemetryHandlerImpl.java,
+ // //depot/google3/java/com/google/android/apps/automotive/templates/host/di/logging/\
+ // ClearcutTelemetryHandler.java,
+ // //depot/google3/logs/proto/wireless/android/automotive/templates/host/\
+ // android_automotive_templates_host_info.proto)
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryHandler.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryHandler.java
new file mode 100644
index 0000000..9d68892
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/logging/TelemetryHandler.java
@@ -0,0 +1,76 @@
+/*
+ * 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.logging;
+
+import android.content.ComponentName;
+import androidx.car.app.FailureResponse;
+import com.android.car.libraries.apphost.logging.TelemetryEvent.UiAction;
+
+/**
+ * Telemetry service abstraction. Implementations are expected to convert these events to their own
+ * representation and send the information to their corresponding backend.
+ */
+public abstract class TelemetryHandler {
+
+ /** Logs a telemetry event for the given {@link TelemetryEvent.Builder}. */
+ public abstract void logCarAppTelemetry(TelemetryEvent.Builder logEventBuilder);
+
+ /**
+ * Logs a telemetry event with the given {@link UiAction}, the provided {@link ComponentName}, and
+ * the provided {@link CarAppApi}.
+ */
+ public void logCarAppApiSuccessTelemetry(ComponentName appName, CarAppApi carAppApi) {
+ TelemetryEvent.Builder builder =
+ TelemetryEvent.newBuilder(UiAction.CAR_APP_API_SUCCESS, appName).setCarAppApi(carAppApi);
+ logCarAppTelemetry(builder);
+ }
+
+ /**
+ * Logs a telemetry event with the given {@link UiAction}, the provided {@link ComponentName}, the
+ * provided {@link CarAppApi}, and the provided {@link CarAppApiErrorType}.
+ */
+ public void logCarAppApiFailureTelemetry(
+ ComponentName appName, CarAppApi carAppApi, CarAppApiErrorType errorType) {
+ TelemetryEvent.Builder builder =
+ TelemetryEvent.newBuilder(UiAction.CAR_APP_API_FAILURE, appName)
+ .setCarAppApi(carAppApi)
+ .setErrorType(errorType);
+
+ logCarAppTelemetry(builder);
+ }
+
+ /** Helper method for getting the telemetry error type based on a {@link FailureResponse}. */
+ public static CarAppApiErrorType getErrorType(FailureResponse failure) {
+ switch (failure.getErrorType()) {
+ case FailureResponse.BUNDLER_EXCEPTION:
+ return CarAppApiErrorType.BUNDLER_EXCEPTION;
+ case FailureResponse.ILLEGAL_STATE_EXCEPTION:
+ return CarAppApiErrorType.ILLEGAL_STATE_EXCEPTION;
+ case FailureResponse.INVALID_PARAMETER_EXCEPTION:
+ return CarAppApiErrorType.INVALID_PARAMETER_EXCEPTION;
+ case FailureResponse.SECURITY_EXCEPTION:
+ return CarAppApiErrorType.SECURITY_EXCEPTION;
+ case FailureResponse.RUNTIME_EXCEPTION:
+ return CarAppApiErrorType.RUNTIME_EXCEPTION;
+ case FailureResponse.REMOTE_EXCEPTION:
+ return CarAppApiErrorType.REMOTE_EXCEPTION;
+ case FailureResponse.UNKNOWN_ERROR:
+ default:
+ // fall-through
+ }
+ return CarAppApiErrorType.UNKNOWN_ERROR;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationHost.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationHost.java
new file mode 100644
index 0000000..41cd8ea
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationHost.java
@@ -0,0 +1,248 @@
+/*
+ * 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.nav;
+
+import androidx.car.app.navigation.INavigationHost;
+import androidx.car.app.navigation.model.Trip;
+import androidx.car.app.serialization.Bundleable;
+import androidx.car.app.serialization.BundlerException;
+import com.android.car.libraries.apphost.AbstractHost;
+import com.android.car.libraries.apphost.Host;
+import com.android.car.libraries.apphost.common.CarAppError;
+import com.android.car.libraries.apphost.common.StringUtils;
+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.android.car.libraries.apphost.logging.TelemetryEvent;
+import com.android.car.libraries.apphost.logging.TelemetryEvent.UiAction;
+import java.io.PrintWriter;
+import java.util.ArrayDeque;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A {@link Host} implementation that handles communication between the client app and the rest of
+ * the host.
+ *
+ * <p>Host services are per app, and live for the duration of a car session.
+ */
+public class NavigationHost extends AbstractHost {
+ private final NavigationManagerDispatcher mDispatcher;
+ private final NavigationHostStub mNavHostStub = new NavigationHostStub();
+
+ @Nullable private Trip mTrip;
+ private final NavigationStateCallback mNavigationStateCallback;
+
+ /** Number of status events to store in a circular buffer for debug reports */
+ private static final int MAX_STATUS_ITEMS = 10;
+
+ /**
+ * A circular buffer which will hold at most {@link #MAX_STATUS_ITEMS}. Items are added at the top
+ * of the list and deleted from the end.
+ */
+ private final ArrayDeque<StatusItem> mStatusItemList = new ArrayDeque<>();
+
+ /** Creates a {@link NavigationHost} instance. */
+ public static NavigationHost create(
+ Object appBinding, TemplateContext templateContext, NavigationStateCallback callback) {
+ return new NavigationHost(
+ NavigationManagerDispatcher.create(appBinding), templateContext, callback);
+ }
+
+ @Override
+ public INavigationHost.Stub getBinder() {
+ assertIsValid();
+ return mNavHostStub;
+ }
+
+ /** Returns the {@link Trip} instance currently set in this host. */
+ @Nullable
+ public Trip getTrip() {
+ assertIsValid();
+ return mTrip;
+ }
+
+ @Override
+ public void reportStatus(PrintWriter pw, Pii piiHandling) {
+ if (mStatusItemList.isEmpty()) {
+ pw.println("No navigation status events stored.");
+ return;
+ }
+ long currentTime = System.currentTimeMillis();
+ // TODO(b/177353816): Update after TableWriter is accessible in the host.
+ pw.printf(
+ "Event | First Event Delta (millis), Num Consecutive Events, Last Event Delta"
+ + " (millis)\n");
+ for (StatusItem item : mStatusItemList) {
+ pw.printf(
+ "%8s| %s, %d, %s\n",
+ item.mEventType.name(),
+ StringUtils.formatDuration(currentTime - item.mInitialEventMillis),
+ item.mNumConsecutiveEvents,
+ StringUtils.formatDuration(currentTime - item.mFinalEventMillis));
+ }
+ }
+
+ @Override
+ public void onDisconnectedEvent() {
+ mNavigationStateCallback.onNavigationEnded();
+ }
+
+ @Override
+ public void onUnboundEvent() {
+ mNavigationStateCallback.onNavigationEnded();
+ }
+
+ private void setTrip(@Nullable Trip trip) {
+ mTrip = trip;
+ }
+
+ private NavigationHost(
+ NavigationManagerDispatcher dispatcher,
+ TemplateContext templateContext,
+ NavigationStateCallback navigationStateCallback) {
+ super(templateContext, LogTags.NAVIGATION);
+ mNavigationStateCallback = navigationStateCallback;
+ mDispatcher = dispatcher;
+ }
+
+ private final class NavigationHostStub extends INavigationHost.Stub {
+ @Override
+ public void updateTrip(Bundleable tripBundle) {
+ runIfValid(
+ "updateTrip",
+ () -> {
+ try {
+ Trip trip = (Trip) tripBundle.get();
+ ThreadUtils.runOnMain(
+ () -> {
+ addStatusItem(StatusItem.EventType.UPDATE);
+ if (mNavigationStateCallback.onUpdateTrip(trip)) {
+ setTrip(trip);
+ }
+ });
+ } catch (BundlerException e) {
+ mTemplateContext
+ .getErrorHandler()
+ .showError(CarAppError.builder(mDispatcher.getAppName()).setCause(e).build());
+ }
+
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(
+ TelemetryEvent.newBuilder(
+ UiAction.NAVIGATION_TRIP_UPDATED,
+ mTemplateContext.getCarAppPackageInfo().getComponentName()));
+ });
+ }
+
+ @Override
+ public void navigationStarted() {
+ runIfValid(
+ "navigationStarted",
+ () -> {
+ L.i(LogTags.NAVIGATION, "%s started navigation", getAppPackageName());
+ ThreadUtils.runOnMain(
+ () -> {
+ addStatusItem(StatusItem.EventType.START);
+ mNavigationStateCallback.onNavigationStarted(
+ () -> {
+ addStatusItem(StatusItem.EventType.STOP);
+ mDispatcher.dispatchStopNavigation(mTemplateContext);
+ });
+ });
+
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(
+ TelemetryEvent.newBuilder(
+ UiAction.NAVIGATION_STARTED,
+ mTemplateContext.getCarAppPackageInfo().getComponentName()));
+ });
+ }
+
+ @Override
+ public void navigationEnded() {
+ if (!isValid()) {
+ L.w(LogTags.NAVIGATION, "Accessed navigationEnded after host became invalidated");
+ }
+ // Run even if not valid so we cleanup state.
+
+ L.i(LogTags.NAVIGATION, "%s ended navigation", getAppPackageName());
+ ThreadUtils.runOnMain(
+ () -> {
+ addStatusItem(StatusItem.EventType.END);
+ mNavigationStateCallback.onNavigationEnded();
+ });
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(
+ TelemetryEvent.newBuilder(
+ UiAction.NAVIGATION_ENDED,
+ mTemplateContext.getCarAppPackageInfo().getComponentName()));
+ }
+ }
+
+ private String getAppPackageName() {
+ return mTemplateContext.getCarAppPackageInfo().getComponentName().getPackageName();
+ }
+
+ private void addStatusItem(StatusItem.EventType eventType) {
+ StatusItem item = mStatusItemList.peekFirst();
+ long timestampMillis = System.currentTimeMillis();
+
+ if (item != null && item.mEventType == eventType) {
+ item.appendTimeStamp(System.currentTimeMillis());
+ return;
+ }
+ item = new StatusItem(eventType, timestampMillis);
+ mStatusItemList.addFirst(item);
+ while (mStatusItemList.size() > MAX_STATUS_ITEMS) {
+ mStatusItemList.removeLast();
+ }
+ }
+
+ /**
+ * Entry for reporting the various events that can be reported on.
+ *
+ * <p>Only saves the time of the first and last event along with a count of the total events.
+ */
+ private static class StatusItem {
+ enum EventType {
+ START,
+ UPDATE,
+ END,
+ STOP
+ };
+
+ final EventType mEventType;
+ final long mInitialEventMillis;
+ int mNumConsecutiveEvents;
+ long mFinalEventMillis;
+
+ StatusItem(EventType eventType, long initialEventMillis) {
+ mEventType = eventType;
+ mInitialEventMillis = initialEventMillis;
+ mNumConsecutiveEvents = 1;
+ mFinalEventMillis = initialEventMillis;
+ }
+
+ public void appendTimeStamp(long timestampMillis) {
+ mNumConsecutiveEvents++;
+ mFinalEventMillis = timestampMillis;
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationManagerDispatcher.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationManagerDispatcher.java
new file mode 100644
index 0000000..eef041a
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationManagerDispatcher.java
@@ -0,0 +1,47 @@
+/*
+ * 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.nav;
+
+import androidx.annotation.AnyThread;
+import androidx.car.app.CarContext;
+import androidx.car.app.navigation.INavigationManager;
+import com.android.car.libraries.apphost.ManagerDispatcher;
+import com.android.car.libraries.apphost.common.NamedAppServiceCall;
+import com.android.car.libraries.apphost.common.OnDoneCallbackStub;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+
+/** Dispatcher of calls to the {@link INavigationManager}. */
+public class NavigationManagerDispatcher extends ManagerDispatcher<INavigationManager> {
+ /** Creates an instance of {@link NavigationManagerDispatcher}. */
+ public static NavigationManagerDispatcher create(Object appBinding) {
+ return new NavigationManagerDispatcher(appBinding);
+ }
+
+ /** Dispatches {@link INavigationManager#onStopNavigation} to the app. */
+ @AnyThread
+ public void dispatchStopNavigation(TemplateContext templateContext) {
+ dispatch(
+ NamedAppServiceCall.create(
+ CarAppApi.STOP_NAVIGATION,
+ (manager, anrToken) ->
+ manager.onStopNavigation(new OnDoneCallbackStub(templateContext, anrToken))));
+ }
+
+ private NavigationManagerDispatcher(Object appBinding) {
+ super(CarContext.NAVIGATION_SERVICE, appBinding);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationStateCallback.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationStateCallback.java
new file mode 100644
index 0000000..7d24819
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/nav/NavigationStateCallback.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.libraries.apphost.nav;
+
+import androidx.car.app.navigation.model.Trip;
+
+/** Handles navigation state change events from {@link NavigationHost}. */
+public interface NavigationStateCallback {
+
+ /**
+ * Notifies that the {@link Trip} set in the {@link NavigationHost} has been updated from the app.
+ */
+ boolean onUpdateTrip(Trip trip);
+
+ /** Notifies that navigation has been started by the app. */
+ void onNavigationStarted(Runnable onNavigationStopRunnable);
+
+ /** Notifies that navigation has been stopped by the app. */
+ void onNavigationEnded();
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppHost.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppHost.java
new file mode 100644
index 0000000..7c1a611
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppHost.java
@@ -0,0 +1,635 @@
+/*
+ * 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.template;
+
+import static com.android.car.libraries.apphost.common.EventManager.EventType.SURFACE_STABLE_AREA;
+import static com.android.car.libraries.apphost.common.EventManager.EventType.SURFACE_VISIBLE_AREA;
+import static com.android.car.libraries.apphost.logging.TelemetryHandler.getErrorType;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.location.Location;
+import android.os.RemoteException;
+import android.view.Surface;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.CarAppPermission;
+import androidx.car.app.FailureResponse;
+import androidx.car.app.IAppHost;
+import androidx.car.app.IAppManager;
+import androidx.car.app.ISurfaceCallback;
+import androidx.car.app.SurfaceContainer;
+import androidx.car.app.model.GridTemplate;
+import androidx.car.app.model.ListTemplate;
+import androidx.car.app.model.LongMessageTemplate;
+import androidx.car.app.model.MessageTemplate;
+import androidx.car.app.model.PaneTemplate;
+import androidx.car.app.model.PlaceListMapTemplate;
+import androidx.car.app.model.SearchTemplate;
+import androidx.car.app.model.Template;
+import androidx.car.app.model.TemplateWrapper;
+import androidx.car.app.model.signin.SignInTemplate;
+import androidx.car.app.navigation.model.NavigationTemplate;
+import androidx.car.app.navigation.model.PlaceListNavigationTemplate;
+import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate;
+import androidx.car.app.serialization.Bundleable;
+import androidx.car.app.serialization.BundlerException;
+import androidx.car.app.versioning.CarAppApiLevels;
+import com.android.car.libraries.apphost.AbstractHost;
+import com.android.car.libraries.apphost.Host;
+import com.android.car.libraries.apphost.common.ANRHandler;
+import com.android.car.libraries.apphost.common.CarAppError;
+import com.android.car.libraries.apphost.common.LocationMediator;
+import com.android.car.libraries.apphost.common.OnDoneCallbackStub;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.common.ThreadUtils;
+import com.android.car.libraries.apphost.distraction.FlowViolationException;
+import com.android.car.libraries.apphost.distraction.OverLimitFlowViolationException;
+import com.android.car.libraries.apphost.distraction.TemplateValidator;
+import com.android.car.libraries.apphost.distraction.checkers.GridTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.ListTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.MessageTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.NavigationTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.PaneTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.PlaceListMapTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.PlaceListNavigationTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.RoutePreviewNavigationTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.SignInTemplateChecker;
+import com.android.car.libraries.apphost.distraction.checkers.TemplateChecker;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+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 com.android.car.libraries.apphost.view.SurfaceProvider;
+import com.android.car.libraries.apphost.view.SurfaceProvider.SurfaceProviderListener;
+import java.io.PrintWriter;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A {@link Host} implementation that handles communication between the client app and the rest of
+ * the host.
+ *
+ * <p>Host services are per app, and live for the duration of a car session.
+ *
+ * <p>A host service keeps a reference to a {@link UIController} object which it delegates UI calls
+ * to, and is responsible for making all the necessary checks to let the operations go through e.g.
+ * check that the backing context (an activity or fragment) is alive, the app is in started state,
+ * etc.
+ *
+ * <p>The {@link UIController} instance may be updated when the backing context is re-created, e.g.
+ * during config changes such as light/dark mode switches.
+ */
+public class AppHost extends AbstractHost {
+ private final IAppHost.Stub mAppHostStub = new AppHostStub();
+ private final AppManagerDispatcher mDispatcher;
+
+ private UIController mUIController;
+ @Nullable private ISurfaceCallback mSurfaceListener;
+ @Nullable private SurfaceContainer mSurfaceContainer;
+ private final AtomicBoolean mIsPendingGetTemplate = new AtomicBoolean(false);
+ private final TemplateValidator mTemplateValidator;
+ private final TelemetryHandler mTelemetryHandler;
+
+ private final SurfaceProvider.SurfaceProviderListener mSurfaceProviderListener =
+ new SurfaceProviderListener() {
+ @Override
+ public void onSurfaceCreated() {
+ L.d(LogTags.TEMPLATE, "SurfaceProvider: Surface created");
+ }
+
+ // call to onVisibleAreaChanged() not allowed on the given receiver.
+ // call to onStableAreaChanged() not allowed on the given receiver.
+ @SuppressWarnings("nullness:method.invocation")
+ @Override
+ public void onSurfaceChanged() {
+ SurfaceContainer container = createOrReuseContainer();
+ mSurfaceContainer = container;
+
+ ISurfaceCallback listener = mSurfaceListener;
+ if (listener != null) {
+ mTemplateContext.getAppDispatcher().dispatchSurfaceAvailable(listener, container);
+ }
+ L.d(LogTags.TEMPLATE, "SurfaceProvider: Surface updated: %s.", container);
+
+ onVisibleAreaChanged();
+ onStableAreaChanged();
+ }
+
+ @Override
+ public void onSurfaceDestroyed() {
+ SurfaceContainer container = createOrReuseContainer();
+ mSurfaceContainer = container;
+
+ ISurfaceCallback listener = mSurfaceListener;
+ if (listener != null) {
+ mTemplateContext.getAppDispatcher().dispatchSurfaceDestroyed(listener, container);
+ }
+
+ mSurfaceContainer = null;
+ L.d(LogTags.TEMPLATE, "SurfaceProvider: Surface destroyed");
+ }
+
+ // call to onSurfaceScroll(float,float) not allowed on the given receiver.
+ @SuppressWarnings("nullness:method.invocation")
+ @Override
+ public void onSurfaceScroll(float distanceX, float distanceY) {
+ AppHost.this.onSurfaceScroll(distanceX, distanceY);
+ }
+
+ // call to onSurfaceScroll(float,float) not allowed on the given receiver.
+ @SuppressWarnings("nullness:method.invocation")
+ @Override
+ public void onSurfaceFling(float velocityX, float velocityY) {
+ AppHost.this.onSurfaceFling(velocityX, velocityY);
+ }
+
+ // call to onSurfaceScroll(float,float) not allowed on the given receiver.
+ @SuppressWarnings("nullness:method.invocation")
+ @Override
+ public void onSurfaceScale(float focusX, float focusY, float scaleFactor) {
+ AppHost.this.onSurfaceScale(focusX, focusY, scaleFactor);
+ }
+
+ private SurfaceContainer createOrReuseContainer() {
+ // dereference of possibly-null reference uiController
+ // dereference of possibly-null reference dispatcher
+ @SuppressWarnings("nullness:dereference.of.nullable")
+ SurfaceProvider provider = mUIController.getSurfaceProvider(mDispatcher.getAppName());
+
+ Surface surface = provider.getSurface();
+ int width = provider.getWidth();
+ int height = provider.getHeight();
+ int dpi = provider.getDpi();
+
+ if (mSurfaceContainer != null
+ && mSurfaceContainer.getSurface() == surface
+ && mSurfaceContainer.getWidth() == width
+ && mSurfaceContainer.getHeight() == height
+ && mSurfaceContainer.getDpi() == dpi) {
+ return mSurfaceContainer;
+ }
+
+ return new SurfaceContainer(surface, width, height, dpi);
+ }
+ };
+
+ /**
+ * Creates a template host service.
+ *
+ * @param uiController the controller to delegate UI calls to. Can be updated with {@link
+ * #setUIController(UIController)} henceforth
+ * @param appBinding the binding to use to dispatch client calls
+ */
+ public static AppHost create(
+ UIController uiController, Object appBinding, TemplateContext templateContext) {
+ return new AppHost(uiController, AppManagerDispatcher.create(appBinding), templateContext);
+ }
+
+ @Override
+ public IAppHost.Stub getBinder() {
+ assertIsValid();
+ return mAppHostStub;
+ }
+
+ @Override
+ public void onCarAppBound() {
+ super.onCarAppBound();
+
+ updateUiControllerListener();
+ mTemplateValidator.reset();
+ getTemplate();
+ }
+
+ @Override
+ public void onNewIntentDispatched() {
+ super.onNewIntentDispatched();
+
+ getTemplate();
+ }
+
+ @Override
+ public void onBindToApp(Intent intent) {
+ super.onBindToApp(intent);
+
+ if (mTemplateContext.getCarHostConfig().isNewTaskFlowIntent(intent)) {
+ mTemplateValidator.reset();
+ }
+ }
+
+ @Override
+ public void reportStatus(PrintWriter pw, Pii piiHandling) {
+ pw.printf("- flow validator: %s\n", mTemplateValidator);
+ pw.printf("- surface: %s\n", mSurfaceContainer);
+ }
+
+ /** Dispatches an on-back-pressed event. */
+ public void onBackPressed() {
+ assertIsValid();
+ mDispatcher.dispatchOnBackPressed(mTemplateContext);
+ }
+
+ /** Informs the app to start or stop sending location updates. */
+ public void trySetEnableLocationUpdates(boolean enable) {
+ assertIsValid();
+
+ // The enableLocationUpdates API is only available for API level 4+.
+ int apiLevel = mTemplateContext.getCarHostConfig().getNegotiatedApi();
+ if (apiLevel <= CarAppApiLevels.LEVEL_3) {
+ L.e(LogTags.APP_HOST, "Attempt to request location updates for app Api level %s", apiLevel);
+ return;
+ }
+
+ if (enable) {
+ mDispatcher.dispatchStartLocationUpdates(mTemplateContext);
+ } else {
+ mDispatcher.dispatchStopLocationUpdates(mTemplateContext);
+ }
+ }
+
+ /** Dispatches a surface scroll event. */
+ public void onSurfaceScroll(float distanceX, float distanceY) {
+ ISurfaceCallback listener = mSurfaceListener;
+ if (listener != null) {
+ mTemplateContext.getAppDispatcher().dispatchOnSurfaceScroll(listener, distanceX, distanceY);
+ L.d(LogTags.TEMPLATE, "SurfaceProvider: Surface scroll: [%f, %f]", distanceX, distanceY);
+ }
+ }
+
+ /** Dispatches a surface fling event. */
+ public void onSurfaceFling(float velocityX, float velocityY) {
+ ISurfaceCallback listener = mSurfaceListener;
+ if (listener != null) {
+ mTemplateContext.getAppDispatcher().dispatchOnSurfaceFling(listener, velocityX, velocityY);
+ L.d(LogTags.TEMPLATE, "SurfaceProvider: Surface fling: [%f, %f]", velocityX, velocityY);
+ }
+ }
+
+ /** Dispatches a surface scale event. */
+ public void onSurfaceScale(float focusX, float focusY, float scaleFactor) {
+ ISurfaceCallback listener = mSurfaceListener;
+ if (listener != null) {
+ mTemplateContext
+ .getAppDispatcher()
+ .dispatchOnSurfaceScale(listener, focusX, focusY, scaleFactor);
+ L.d(LogTags.TEMPLATE, "SurfaceProvider: Surface scale: [%f]", scaleFactor);
+ }
+ }
+
+ /**
+ * Updates the current {@link UIController}.
+ *
+ * <p>This is normally called when the caller detects that the controller set in the service is
+ * stale due to its backing context being destroyed.
+ */
+ public void setUIController(UIController uiController) {
+ assertIsValid();
+
+ removeUiControllerListener();
+ mUIController = uiController;
+
+ updateUiControllerListener();
+ }
+
+ /** Returns the {@link UIController} attached to this app host. */
+ public UIController getUIController() {
+ assertIsValid();
+ return mUIController;
+ }
+
+ /**
+ * Returns the {@link TemplateValidator} to use to validate whether the templates handled by this
+ * host \ abide by the flow rules.
+ */
+ @VisibleForTesting
+ public TemplateValidator getTemplateValidator() {
+ return mTemplateValidator;
+ }
+
+ /** Registers a {@link TemplateChecker} for a host-only {@link Template}. */
+ public <T extends Template> void registerHostTemplateChecker(
+ Class<T> templateClass, TemplateChecker<T> templateChecker) {
+ mTemplateValidator.registerTemplateChecker(templateClass, templateChecker);
+ }
+
+ @Override
+ public void setTemplateContext(TemplateContext templateContext) {
+ removeEventSubscriptions();
+
+ super.setTemplateContext(templateContext);
+ templateContext.registerAppHostService(TemplateValidator.class, mTemplateValidator);
+ updateEventSubscriptions();
+ }
+
+ @Override
+ public void onDisconnectedEvent() {
+ removeUiControllerListener();
+ }
+
+ private void getTemplate() {
+ boolean wasPendingTemplate = mIsPendingGetTemplate.getAndSet(true);
+ if (wasPendingTemplate) {
+ // Ignore extra invalidate calls between templates being returned.
+ return;
+ }
+
+ mDispatcher.dispatchGetTemplate(this::getTemplateAppServiceCall);
+ }
+
+ private void getTemplateAppServiceCall(IAppManager manager, ANRHandler.ANRToken anrToken)
+ throws RemoteException {
+ manager.getTemplate(
+ new OnDoneCallbackStub(mTemplateContext, anrToken) {
+ @Override
+ public void onSuccess(@Nullable Bundleable response) {
+ super.onSuccess(response);
+ mIsPendingGetTemplate.set(false);
+ ComponentName appName = mDispatcher.getAppName();
+
+ RuntimeException toThrow = null;
+ try {
+ TemplateWrapper wrapper = (TemplateWrapper) checkNotNull(response).get();
+
+ // This checks whether this template meets our task flow
+ // restriction guideline and will throw if the template should not
+ // be added.
+ mTemplateValidator.validateFlow(wrapper);
+ mTemplateValidator.validateHasRequiredPermissions(mTemplateContext, wrapper);
+
+ mUIController.setTemplate(appName, wrapper);
+ } catch (BundlerException e) {
+ mTelemetryHandler.logCarAppApiFailureTelemetry(
+ appName, CarAppApi.GET_TEMPLATE, getErrorType(new FailureResponse(e)));
+
+ mTemplateContext
+ .getErrorHandler()
+ .showError(
+ CarAppError.builder(appName)
+ .setCause(e)
+ .setDebugMessage("Invalid template")
+ .build());
+ } catch (SecurityException e) {
+ mTelemetryHandler.logCarAppApiFailureTelemetry(
+ appName, CarAppApi.GET_TEMPLATE, getErrorType(new FailureResponse(e)));
+
+ mTemplateContext
+ .getErrorHandler()
+ .showError(
+ CarAppError.builder(appName)
+ .setCause(e)
+ .setType(CarAppError.Type.MISSING_PERMISSION)
+ .build());
+ toThrow = e;
+ } catch (FlowViolationException e) {
+ mTelemetryHandler.logCarAppTelemetry(
+ TelemetryEvent.newBuilder(
+ e instanceof OverLimitFlowViolationException
+ ? UiAction.TEMPLATE_FLOW_LIMIT_EXCEEDED
+ : UiAction.TEMPLATE_FLOW_INVALID_BACK,
+ appName));
+
+ mTemplateContext
+ .getErrorHandler()
+ .showError(
+ CarAppError.builder(appName)
+ .setCause(e)
+ .setDebugMessage("Template flow restrictions violated")
+ .build());
+ toThrow = new IllegalStateException(e);
+ } catch (RuntimeException e) {
+ mTelemetryHandler.logCarAppApiFailureTelemetry(
+ appName, CarAppApi.GET_TEMPLATE, getErrorType(new FailureResponse(e)));
+
+ mTemplateContext
+ .getErrorHandler()
+ .showError(CarAppError.builder(appName).setCause(e).build());
+ toThrow = e;
+ }
+
+ if (toThrow != null) {
+ // Crash the client process if the template returned does not pass validations.
+ throw toThrow;
+ }
+ }
+
+ @Override
+ public void onFailure(Bundleable failureResponse) {
+ super.onFailure(failureResponse);
+ mIsPendingGetTemplate.set(false);
+ }
+ });
+ }
+
+ /**
+ * Dispatches a call to the template app if the surface has been created and there is a visible
+ * area available.
+ */
+ private void onVisibleAreaChanged() {
+ // Do not fire the visible area changed event until at least after the surfaceContainer
+ // is created, which is triggered by the onSurfaceChanged callback.
+ if (mSurfaceContainer == null) {
+ return;
+ }
+
+ Rect visibleArea = mTemplateContext.getSurfaceInfoProvider().getVisibleArea();
+ if (visibleArea == null) {
+ return;
+ }
+ ISurfaceCallback listener = mSurfaceListener;
+ if (listener != null) {
+ mTemplateContext.getAppDispatcher().dispatchVisibleAreaChanged(listener, visibleArea);
+ }
+ L.d(LogTags.TEMPLATE, "SurfaceProvider: onVisibleAreaChanged: visibleArea: [%s]", visibleArea);
+ }
+
+ /**
+ * Dispatches a call to the template app if the surface has been created and there is a stable
+ * area visible.
+ */
+ private void onStableAreaChanged() {
+ // Do not fire the Insets changed event until at least after the surfaceContainer
+ // is created, which is triggered by the onSurfaceChanged callback.
+ if (mSurfaceContainer == null) {
+ return;
+ }
+
+ Rect stableArea = mTemplateContext.getSurfaceInfoProvider().getStableArea();
+ if (stableArea == null) {
+ return;
+ }
+ ISurfaceCallback listener = mSurfaceListener;
+ if (listener != null) {
+ mTemplateContext.getAppDispatcher().dispatchStableAreaChanged(listener, stableArea);
+ }
+ L.d(LogTags.DISTRACTION, "SurfaceProvider: onStableAreaChanged: stableArea: [%s]", stableArea);
+ }
+
+ private void registerTemplateValidators() {
+ mTemplateValidator.registerTemplateChecker(GridTemplate.class, new GridTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(ListTemplate.class, new ListTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(MessageTemplate.class, new MessageTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(
+ NavigationTemplate.class, new NavigationTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(PaneTemplate.class, new PaneTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(
+ PlaceListMapTemplate.class, new PlaceListMapTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(
+ PlaceListNavigationTemplate.class, new PlaceListNavigationTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(
+ RoutePreviewNavigationTemplate.class, new RoutePreviewNavigationTemplateChecker());
+ mTemplateValidator.registerTemplateChecker(SignInTemplate.class, new SignInTemplateChecker());
+
+ // Templates that don't require refresh and permission checks.
+ mTemplateValidator.registerTemplateChecker(
+ LongMessageTemplate.class, (newTemplate, oldTemplate) -> true);
+ mTemplateValidator.registerTemplateChecker(
+ SearchTemplate.class, (newTemplate, oldTemplate) -> true);
+ }
+
+ private void updateUiControllerListener() {
+ SurfaceProvider surfaceProvider =
+ mUIController.getSurfaceProvider(
+ mTemplateContext.getCarAppPackageInfo().getComponentName());
+ if (surfaceProvider == null) {
+ // We should always be able to access the surface provider at the point where the ui
+ // controller is set.
+ throw new IllegalStateException(
+ "Can't get surface provider for "
+ + mTemplateContext.getCarAppPackageInfo().getComponentName().flattenToShortString());
+ }
+ surfaceProvider.setListener(mSurfaceProviderListener);
+ }
+
+ private void removeUiControllerListener() {
+ // Remove any outstanding surface listeners whenever the app crashes, otherwise the listener
+ // may send onSurfaceDestroyed calls when the app is not bound.
+ mUIController
+ .getSurfaceProvider(mTemplateContext.getCarAppPackageInfo().getComponentName())
+ .setListener(null);
+ }
+
+ private void updateEventSubscriptions() {
+ mTemplateContext
+ .getEventManager()
+ .subscribeEvent(this, SURFACE_VISIBLE_AREA, AppHost.this::onVisibleAreaChanged);
+ mTemplateContext
+ .getEventManager()
+ .subscribeEvent(this, SURFACE_STABLE_AREA, AppHost.this::onStableAreaChanged);
+ }
+
+ private void removeEventSubscriptions() {
+ mTemplateContext.getEventManager().unsubscribeEvent(this, SURFACE_VISIBLE_AREA);
+ mTemplateContext.getEventManager().unsubscribeEvent(this, SURFACE_STABLE_AREA);
+ }
+
+ @SuppressWarnings("nullness")
+ private AppHost(
+ UIController uiController, AppManagerDispatcher dispatcher, TemplateContext templateContext) {
+ super(templateContext, LogTags.APP_HOST);
+ mUIController = uiController;
+ mDispatcher = dispatcher;
+ mTemplateValidator =
+ TemplateValidator.create(
+ templateContext.getConstraintsProvider().getTemplateStackMaxSize());
+ mTelemetryHandler = templateContext.getTelemetryHandler();
+
+ templateContext.registerAppHostService(TemplateValidator.class, mTemplateValidator);
+
+ registerTemplateValidators();
+ updateUiControllerListener();
+ updateEventSubscriptions();
+ }
+
+ /**
+ * A {@link IAppHost.Stub} implementation that used to receive calls to the app host API from the
+ * client.
+ */
+ private final class AppHostStub extends IAppHost.Stub {
+ @Override
+ public void invalidate() {
+ runIfValid("invalidate", AppHost.this::getTemplate);
+ }
+
+ @Override
+ public void showToast(CharSequence text, int duration) {
+ ThreadUtils.runOnMain(
+ () ->
+ runIfValid(
+ "showToast",
+ () -> mTemplateContext.getToastController().showToast(text, duration)));
+ }
+
+ @Override
+ public void setSurfaceCallback(@Nullable ISurfaceCallback listener) {
+ runIfValid(
+ "setSurfaceCallback",
+ () -> {
+ ComponentName appName = mDispatcher.getAppName();
+ L.d(LogTags.TEMPLATE, "setSurfaceListener for %s", appName);
+
+ Context appConfigurationContext = mTemplateContext.getAppConfigurationContext();
+ if (appConfigurationContext == null) {
+ L.e(LogTags.TEMPLATE, "App configuration context is null");
+ return;
+ }
+
+ try {
+ CarAppPermission.checkHasLibraryPermission(
+ appConfigurationContext, CarAppPermission.ACCESS_SURFACE);
+ } catch (SecurityException e) {
+ // Catch the Exception here to log in host before throwing to the client
+ // app.
+ L.w(
+ LogTags.TEMPLATE,
+ e,
+ "App %s trying to access surface when the permission was not" + " granted",
+ appName);
+
+ throw new SecurityException(e);
+ }
+
+ ThreadUtils.runOnMain(
+ () -> {
+ mSurfaceListener = listener;
+ if (mSurfaceListener == null) {
+ return;
+ }
+
+ if (mSurfaceContainer != null) {
+ mSurfaceProviderListener.onSurfaceChanged();
+ }
+ });
+ });
+ }
+
+ @Override
+ public void sendLocation(Location location) {
+ ThreadUtils.runOnMain(
+ () ->
+ runIfValid(
+ "sendLocation",
+ () ->
+ Objects.requireNonNull(
+ mTemplateContext.getAppHostService(LocationMediator.class))
+ .setAppLocation(location)));
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppManagerDispatcher.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppManagerDispatcher.java
new file mode 100644
index 0000000..a1a6ccb
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/AppManagerDispatcher.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.template;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.MainThread;
+import androidx.car.app.CarContext;
+import androidx.car.app.IAppManager;
+import com.android.car.libraries.apphost.ManagerDispatcher;
+import com.android.car.libraries.apphost.common.AppServiceCall;
+import com.android.car.libraries.apphost.common.NamedAppServiceCall;
+import com.android.car.libraries.apphost.common.OnDoneCallbackStub;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.CarAppApi;
+
+/** Dispatcher of calls to the {@link IAppManager}. */
+public class AppManagerDispatcher extends ManagerDispatcher<IAppManager> {
+ /** Creates an instance of {@link AppManagerDispatcher} with the given app binding object. */
+ public static AppManagerDispatcher create(Object appBinding) {
+ return new AppManagerDispatcher(appBinding);
+ }
+
+ /** Dispatches {@link IAppManager#getTemplate} to the app. */
+ @AnyThread
+ public void dispatchGetTemplate(AppServiceCall<IAppManager> getTemplateCall) {
+ dispatch(NamedAppServiceCall.create(CarAppApi.GET_TEMPLATE, getTemplateCall));
+ }
+
+ /** Dispatches {@link IAppManager#onBackPressed} to the app. */
+ @AnyThread
+ public void dispatchOnBackPressed(TemplateContext templateContext) {
+ dispatch(
+ NamedAppServiceCall.create(
+ CarAppApi.ON_BACK_PRESSED,
+ (manager, anrToken) ->
+ manager.onBackPressed(new OnDoneCallbackStub(templateContext, anrToken))));
+ }
+
+ /** Dispatches {@link IAppManager#startLocationUpdates} to the app. */
+ @MainThread
+ public void dispatchStartLocationUpdates(TemplateContext templateContext) {
+ dispatch(
+ NamedAppServiceCall.create(
+ CarAppApi.START_LOCATION_UPDATES,
+ (manager, anrToken) ->
+ manager.startLocationUpdates(new OnDoneCallbackStub(templateContext, anrToken))));
+ }
+
+ /** Dispatches {@link IAppManager#stopLocationUpdates} to the app. */
+ @MainThread
+ public void dispatchStopLocationUpdates(TemplateContext templateContext) {
+ dispatch(
+ NamedAppServiceCall.create(
+ CarAppApi.STOP_LOCATION_UPDATES,
+ (manager, anrToken) ->
+ manager.stopLocationUpdates(new OnDoneCallbackStub(templateContext, anrToken))));
+ }
+
+ private AppManagerDispatcher(Object appBinding) {
+ super(CarContext.APP_SERVICE, appBinding);
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/ConstraintHost.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/ConstraintHost.java
new file mode 100644
index 0000000..bdebe3c
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/ConstraintHost.java
@@ -0,0 +1,71 @@
+/*
+ * 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.template;
+
+import androidx.car.app.constraints.IConstraintHost;
+import com.android.car.libraries.apphost.AbstractHost;
+import com.android.car.libraries.apphost.Host;
+import com.android.car.libraries.apphost.common.TemplateContext;
+import com.android.car.libraries.apphost.logging.ContentLimitQuery;
+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;
+
+/**
+ * A {@link Host} implementation that handles constraints enforced on the connecting app.
+ *
+ * <p>Host services are per app, and live for the duration of a car session.
+ */
+public final class ConstraintHost extends AbstractHost {
+ private final IConstraintHost.Stub mHostStub = new ConstraintHostStub();
+
+ /** Creates a template host service. */
+ public static ConstraintHost create(TemplateContext templateContext) {
+ return new ConstraintHost(templateContext);
+ }
+
+ @Override
+ public IConstraintHost.Stub getBinder() {
+ assertIsValid();
+ return mHostStub;
+ }
+
+ private ConstraintHost(TemplateContext templateContext) {
+ super(templateContext, LogTags.CONSTRAINT);
+ }
+
+ /**
+ * A {@link IConstraintHost.Stub} implementation that used to receive calls to the constraint host
+ * API from the client.
+ */
+ private final class ConstraintHostStub extends IConstraintHost.Stub {
+ @Override
+ public int getContentLimit(int contentType) {
+ if (!isValid()) {
+ L.w(LogTags.CONSTRAINT, "Accessed getContentLimit after host became invalidated");
+ }
+ int contentValue = mTemplateContext.getConstraintsProvider().getContentLimit(contentType);
+ mTemplateContext
+ .getTelemetryHandler()
+ .logCarAppTelemetry(
+ TelemetryEvent.newBuilder(UiAction.CONTENT_LIMIT_QUERY)
+ .setCarAppContentLimitQuery(
+ ContentLimitQuery.getContentLimitQuery(contentType, contentValue)));
+ return contentValue;
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/UIController.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/UIController.java
new file mode 100644
index 0000000..666b5d6
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/UIController.java
@@ -0,0 +1,41 @@
+/*
+ * 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.template;
+
+import android.content.ComponentName;
+import androidx.car.app.model.TemplateWrapper;
+import com.android.car.libraries.apphost.view.SurfaceProvider;
+
+/**
+ * Implements the UI operations that a client app may trigger in the UI.
+ *
+ * <p>This is normally implemented by a backing context such as an activity or fragment that a given
+ * template host is connected to.
+ *
+ * <p>The methods in this interface are tagged with an {@code appName} parameter that indicates
+ * which application the operation is intended to. The controller must drop the call if it is not
+ * currently connected to that app.
+ *
+ * <p>These methods can also drop the calls, or return {@code null} if the backing context is not
+ * available, e.g. because it's been collected by the GC or explicitly cleared.
+ */
+public interface UIController {
+ /** Sets the {@link TemplateWrapper} to display in the given app's view. */
+ void setTemplate(ComponentName appName, TemplateWrapper template);
+
+ /** Returns the {@link SurfaceProvider} for the given app. */
+ SurfaceProvider getSurfaceProvider(ComponentName appName);
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionStripWrapper.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionStripWrapper.java
new file mode 100644
index 0000000..53bf270
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionStripWrapper.java
@@ -0,0 +1,95 @@
+/*
+ * 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.template.view.model;
+
+import androidx.car.app.model.Action;
+import androidx.car.app.model.ActionStrip;
+import com.android.car.libraries.apphost.template.view.model.ActionWrapper.OnClickListener;
+import java.util.ArrayList;
+import java.util.List;
+
+/** A host side wrapper for {@link ActionStrip} to allow additional callbacks to the host. */
+public final class ActionStripWrapper {
+ /**
+ * An invalid focused action index.
+ *
+ * <p>If this value is set, the focus will remain at the user's last focused button.
+ */
+ public static final int INVALID_FOCUSED_ACTION_INDEX = -1;
+
+ private final List<ActionWrapper> mActions;
+ private int mFocusedActionIndex;
+
+ /**
+ * Instantiates an {@link ActionStripWrapper}.
+ *
+ * <p>The optional {@link OnClickListener} allows the host to be notified when an action is
+ * clicked.
+ */
+ public ActionStripWrapper(List<ActionWrapper> actionWrappers, int focusedActionIndex) {
+ this.mActions = actionWrappers;
+ this.mFocusedActionIndex = focusedActionIndex;
+ }
+
+ /** Returns the list of {@link ActionWrapper} in the action strip. */
+ public List<ActionWrapper> getActions() {
+ return mActions;
+ }
+
+ /**
+ * Returns the focused action index determined by the host.
+ *
+ * <p>The value of {@link #INVALID_FOCUSED_ACTION_INDEX} means that the host did not specify any
+ * action button to focus, in which case the focus will remain at the user's last focused button.
+ */
+ public int getFocusedActionIndex() {
+ return mFocusedActionIndex;
+ }
+
+ /** The builder of {@link ActionStripWrapper}. */
+ public static final class Builder {
+ private final List<ActionWrapper> mActions;
+ private int mFocusedActionIndex = INVALID_FOCUSED_ACTION_INDEX;
+
+ /** Creates an {@link Builder} instance with the given list of {@link ActionWrapper}s. */
+ public Builder(List<ActionWrapper> actions) {
+ this.mActions = actions;
+ }
+
+ /** Creates an {@link Builder} instance with the given {@link ActionStrip}. */
+ public Builder(ActionStrip actionStrip) {
+ List<ActionWrapper> actions = new ArrayList<>();
+ for (Action action : actionStrip.getActions()) {
+ actions.add(new ActionWrapper.Builder(action).build());
+ }
+ this.mActions = actions;
+ }
+
+ /** Sets the index of the action button to focus. */
+ public Builder setFocusedActionIndex(int index) {
+ this.mFocusedActionIndex = index;
+ return this;
+ }
+
+ /** Constructs an {@link ActionStripWrapper} instance defined by this builder. */
+ public ActionStripWrapper build() {
+ if (mFocusedActionIndex < 0 || mFocusedActionIndex >= mActions.size()) {
+ mFocusedActionIndex = INVALID_FOCUSED_ACTION_INDEX;
+ }
+ return new ActionStripWrapper(mActions, mFocusedActionIndex);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionWrapper.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionWrapper.java
new file mode 100644
index 0000000..282fde2
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/ActionWrapper.java
@@ -0,0 +1,72 @@
+/*
+ * 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.template.view.model;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.Action;
+
+/** A host side wrapper for {@link Action} to allow additional callbacks to the host. */
+public class ActionWrapper {
+ /** A host-side on-click listener. */
+ public interface OnClickListener {
+ /** Called when the user clicks the action. */
+ void onClick();
+ }
+
+ private final Action mAction;
+ @Nullable private final OnClickListener mOnClickListener;
+
+ /** Returns the wrapped action. */
+ @NonNull
+ public Action get() {
+ return mAction;
+ }
+
+ /** Returns the host-side on-click listener. */
+ @Nullable
+ public OnClickListener getOnClickListener() {
+ return mOnClickListener;
+ }
+
+ /** Instantiates an {@link ActionWrapper}. */
+ private ActionWrapper(Action action, @Nullable OnClickListener onClickListener) {
+ this.mAction = action;
+ this.mOnClickListener = onClickListener;
+ }
+
+ /** The builder of {@link ActionWrapper}. */
+ public static final class Builder {
+ private final Action mAction;
+ @Nullable private OnClickListener mOnClickListener;
+
+ /** Creates an {@link Builder} instance with the given {@link Action}. */
+ public Builder(Action action) {
+ this.mAction = action;
+ }
+
+ /** Sets the host-side {@link OnClickListener}. */
+ public Builder setOnClickListener(@Nullable OnClickListener onClickListener) {
+ this.mOnClickListener = onClickListener;
+ return this;
+ }
+
+ /** Constructs an {@link ActionWrapper} instance defined by this builder. */
+ public ActionWrapper build() {
+ return new ActionWrapper(mAction, mOnClickListener);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapper.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapper.java
new file mode 100644
index 0000000..294dc6d
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapper.java
@@ -0,0 +1,644 @@
+/*
+ * 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.template.view.model;
+
+import static com.android.car.libraries.apphost.template.view.model.RowWrapper.ROW_FLAG_NONE;
+
+import android.content.Context;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarColor;
+import androidx.car.app.model.CarIcon;
+import androidx.car.app.model.CarLocation;
+import androidx.car.app.model.CarText;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.model.Metadata;
+import androidx.car.app.model.OnItemVisibilityChangedDelegate;
+import androidx.car.app.model.OnSelectedDelegate;
+import androidx.car.app.model.Pane;
+import androidx.car.app.model.Place;
+import androidx.car.app.model.PlaceMarker;
+import androidx.car.app.model.Row;
+import androidx.car.app.model.SectionedItemList;
+import androidx.car.app.model.Toggle;
+import com.android.car.libraries.apphost.distraction.constraints.RowConstraints;
+import com.android.car.libraries.apphost.distraction.constraints.RowListConstraints;
+import com.android.car.libraries.apphost.logging.L;
+import com.android.car.libraries.apphost.logging.LogTags;
+import com.android.car.libraries.apphost.template.view.model.RowWrapper.RowFlags;
+import com.google.common.collect.ImmutableList;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A host side wrapper for both {@link ItemList} and {@link Pane} to allow additional metadata such
+ * as a {@link androidx.car.app.model.Place} for each individual row and/or {@link
+ * RowListConstraints}.
+ */
+public class RowListWrapper {
+ /** Represents different flags to determine how to render the list. */
+ // TODO(b/174601019): clean this up along with RowFlags
+ @IntDef(
+ flag = true,
+ value = {
+ LIST_FLAGS_NONE,
+ LIST_FLAGS_SELECTABLE_USE_RADIO_BUTTONS,
+ LIST_FLAGS_SELECTABLE_HIGHLIGHT_ROW,
+ LIST_FLAGS_SELECTABLE_FOCUS_SELECT_ROW,
+ LIST_FLAGS_SELECTABLE_SCROLL_TO_ROW,
+ LIST_FLAGS_RENDER_TITLE_AS_SECONDARY,
+ LIST_FLAGS_TEMPLATE_HAS_LARGE_IMAGE,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface ListFlags {}
+
+ public static final int LIST_FLAGS_NONE = (1 << 0);
+
+ /** The list is selectable, and selection should be rendered with radio buttons. */
+ public static final int LIST_FLAGS_SELECTABLE_USE_RADIO_BUTTONS = (1 << 1);
+
+ /** The list is selectable, and selection should be rendered by highlighting the row. */
+ public static final int LIST_FLAGS_SELECTABLE_HIGHLIGHT_ROW = (1 << 2);
+
+ /** The list is selectable, and focus on a row would select it. */
+ public static final int LIST_FLAGS_SELECTABLE_FOCUS_SELECT_ROW = (1 << 3);
+
+ /** The list is selectable, and selection will scroll the list to the selected row. */
+ public static final int LIST_FLAGS_SELECTABLE_SCROLL_TO_ROW = (1 << 4);
+
+ /** Renders the title of the rows as secondary text. */
+ public static final int LIST_FLAGS_RENDER_TITLE_AS_SECONDARY = (1 << 5);
+
+ /** Whether the list is placed alongside an image that needs to scroll with the list. */
+ public static final int LIST_FLAGS_TEMPLATE_HAS_LARGE_IMAGE = (1 << 6);
+
+ /** Whether the list should hide the dividers between the rows. */
+ public static final int LIST_FLAGS_HIDE_ROW_DIVIDERS = (1 << 7);
+
+ /** The default flags to use for selectable lists. */
+ private static final int DEFAULT_SELECTABLE_LIST_FLAGS = LIST_FLAGS_SELECTABLE_USE_RADIO_BUTTONS;
+
+ private final boolean mIsLoading;
+ private final boolean mIsRefresh;
+ @Nullable private final List<Object> mRowList;
+ @Nullable private final CarText mEmptyListText;
+ @Nullable private final CarIcon mImage;
+ @Nullable private final OnItemVisibilityChangedDelegate mOnItemVisibilityChangedDelegate;
+ @Nullable private final Runnable mOnRepeatedSelectionCallback;
+ private final List<RowWrapper> mRowWrappers;
+ private final RowListConstraints mRowListConstraints;
+ @ListFlags private final int mListFlags;
+ private final boolean mIsHalfList;
+
+ /** Returns a builder of {@link RowListWrapper} that wraps the given {@link ItemList}. */
+ public static Builder wrap(Context context, @Nullable ItemList itemList) {
+ if (itemList == null) {
+ return new Builder(context);
+ }
+
+ @SuppressWarnings("unchecked")
+ List<Object> rows = (List) itemList.getItems();
+ Builder builder =
+ new Builder(context)
+ .setRows(rows)
+
+ // Set the default flags for the list, which can be overridden by the caller
+ // to the builder.
+ .setListFlags(getDefaultListFlags(itemList))
+ .setEmptyListText(itemList.getNoItemsMessage())
+ .setOnItemVisibilityChangedDelegate(itemList.getOnItemVisibilityChangedDelegate());
+
+ OnSelectedDelegate onSelectedDelegate = itemList.getOnSelectedDelegate();
+ if (onSelectedDelegate != null) {
+ // Create a selection group for the rows that encompasses the entire list.
+ // The selection groups keep a mutable selection index, and allow for having multiple
+ // selection groups (e.g. different sections of radio buttons) within the same list.
+ builder.setSelectionGroup(
+ SelectionGroup.create(
+ 0, rows.size() - 1, itemList.getSelectedIndex(), onSelectedDelegate));
+ }
+
+ return builder;
+ }
+
+ /** Returns a builder of {@link RowListWrapper} that wraps the given {@link SectionedItemList}. */
+ public static Builder wrap(Context context, List<SectionedItemList> sectionLists) {
+ if (sectionLists.isEmpty()) {
+ return new Builder(context);
+ }
+
+ @SuppressWarnings("unchecked")
+ List<Object> rows = (List) sectionLists;
+ return new Builder(context).setRows(rows);
+ }
+
+ /** Returns a builder of {@link RowListWrapper} that wraps the given {@link Pane}. */
+ public static Builder wrap(Context context, @Nullable Pane pane) {
+ if (pane == null) {
+ L.w(LogTags.TEMPLATE, "Pane is expected on the template but not set");
+ return new Builder(context);
+ }
+
+ // TODO(b/205522074): large image and dividers are specific to pane and the UI hierarchy between
+ // list and pane is diverging more and more. Investigate whether we can decouple the two.
+ int flags = LIST_FLAGS_HIDE_ROW_DIVIDERS;
+ if (pane.getImage() != null) {
+ flags |= LIST_FLAGS_TEMPLATE_HAS_LARGE_IMAGE;
+ }
+
+ return new Builder(context)
+ .setRows(new ArrayList<>(pane.getRows()))
+ .setListFlags(flags)
+ .setImage(pane.getImage())
+ .setIsLoading(pane.isLoading());
+ }
+
+ /** Returns the list flags to use by default for the given {@link ItemList}. */
+ @ListFlags
+ public static int getDefaultListFlags(@Nullable ItemList itemList) {
+ return itemList != null && itemList.getOnSelectedDelegate() != null
+ ? DEFAULT_SELECTABLE_LIST_FLAGS
+ : LIST_FLAGS_NONE;
+ }
+
+ /** Returns a builder configured with the values from this {@link RowListWrapper} instance. */
+ public Builder newBuilder(Context context) {
+ return new Builder(context, this);
+ }
+
+ /**
+ * Returns the list of rows that make up this list.
+ *
+ * @see Builder#setRows(List)
+ */
+ @Nullable
+ public List<Object> getRows() {
+ return mRowList == null ? null : ImmutableList.copyOf(mRowList);
+ }
+
+ /** Returns the image that should be shown alongside the row list. */
+ @Nullable
+ public CarIcon getImage() {
+ return mImage;
+ }
+
+ /**
+ * Returns the flags that control how to render the list.
+ *
+ * @see Builder#setListFlags(int)
+ */
+ @ListFlags
+ public int getListFlags() {
+ return mListFlags;
+ }
+
+ /**
+ * Returns the delegate to use to notify when when the visibility of items in the list change, for
+ * example during scroll.
+ *
+ * @see Builder#setOnItemVisibilityChangedDelegate(OnItemVisibilityChangedDelegate)
+ */
+ @Nullable
+ public OnItemVisibilityChangedDelegate getOnItemVisibilityChangedDelegate() {
+ return mOnItemVisibilityChangedDelegate;
+ }
+
+ /**
+ * Returns the callback for when a row is repeatedly selected.
+ *
+ * @see Builder#setOnRepeatedSelectionCallback
+ */
+ @Nullable
+ public Runnable getRepeatedSelectionCallback() {
+ return mOnRepeatedSelectionCallback;
+ }
+
+ /** Returns whether the list has no rows. */
+ public boolean isEmpty() {
+ return mRowWrappers.isEmpty();
+ }
+
+ /**
+ * Returns the {@link RowListConstraints} that define the restrictions to apply to the list.
+ *
+ * @see Builder#setRowListConstraints(RowListConstraints)
+ */
+ public RowListConstraints getRowListConstraints() {
+ return mRowListConstraints;
+ }
+
+ /**
+ * Returns the text to display when the list is empty or {@code null} to not display any text.
+ *
+ * @see Builder#setEmptyListText(CarText)
+ */
+ @Nullable
+ public CarText getEmptyListText() {
+ return mEmptyListText;
+ }
+
+ /** Returns the list of {@link RowWrapper} instances that wrap the rows in the list. */
+ public List<RowWrapper> getRowWrappers() {
+ return mRowWrappers;
+ }
+
+ /**
+ * Returns whether the list is in loading state.
+ *
+ * @see Builder#setIsLoading(boolean)
+ */
+ public boolean isLoading() {
+ return mIsLoading;
+ }
+
+ /**
+ * Returns whether the list is in loading state.
+ *
+ * @see Builder#setIsRefresh(boolean)
+ */
+ public boolean isRefresh() {
+ return mIsRefresh;
+ }
+
+ /**
+ * Returns whether this is a half list, as opposed to a full width list.
+ *
+ * @see Builder#setIsHalfList(boolean)
+ */
+ public boolean isHalfList() {
+ return mIsHalfList;
+ }
+
+ /**
+ * Builds the {@link RowWrapper}s for a given list, expanding any sub-lists embedded within it.
+ */
+ @SuppressWarnings("RestrictTo")
+ private static ImmutableList<RowWrapper> buildRowWrappers(
+ Context context,
+ @Nullable List<Object> rowList,
+ @Nullable SelectionGroup selectionGroup,
+ RowListConstraints rowListConstraints,
+ @Nullable CarText selectedText,
+ @RowFlags int rowFlags,
+ @ListFlags int listFlags,
+ int startIndex,
+ boolean isHalfList) {
+ if (rowList == null || rowList.isEmpty()) {
+ return ImmutableList.of();
+ }
+
+ // If selectable lists are disallowed, set the selection group to null, which effectively
+ // disables selection.
+ if (!rowListConstraints.getAllowSelectableLists()) {
+ L.w(LogTags.TEMPLATE, "Selectable lists disallowed for template this list");
+ selectionGroup = null;
+ }
+
+ int labelIndex = 1;
+ ImmutableList.Builder<RowWrapper> wrapperListBuilder = new ImmutableList.Builder<>();
+
+ // Sub-lists are expanded inline in this list and become part of it. This size is the
+ // number of rows accounting for any such sub-list expansions.
+ int expandedSize = 0;
+
+ for (Object rowObj : rowList) {
+ // The row is a sub-list: we will expand it and add its rows to the parent list.
+ if (rowObj instanceof SectionedItemList) {
+ SectionedItemList section = (SectionedItemList) rowObj;
+ ItemList subList = section.getItemList();
+
+ if (subList == null || subList.getItems().isEmpty()) {
+ // This should never happen as the client side should prevent empty sub-lists.
+ L.e(LogTags.TEMPLATE, "Found empty sub-list, skipping...");
+ continue;
+ }
+
+ CarText header = section.getHeader();
+ if (header == null) {
+ // This should never happen as the client side should prevent null headers.
+ L.e(LogTags.TEMPLATE, "Header is expected on the section but not set, skipping...");
+ continue;
+ }
+
+ // Create a row representing the header.
+ Row headerRow = new Row.Builder().setTitle(header.toCharSequence()).build();
+ wrapperListBuilder.add(
+ RowWrapper.wrap(headerRow, startIndex + expandedSize)
+ .setListFlags(listFlags)
+ .setRowFlags(rowFlags | RowWrapper.ROW_FLAG_SECTION_HEADER)
+ .setIsHalfList(isHalfList)
+ .setRowConstraints(rowListConstraints.getRowConstraints())
+ .build());
+ expandedSize++;
+
+ // Create wrappers for each row in the sublist.
+ int subListSize = subList.getItems().size();
+ List<RowWrapper> subWrappers =
+ createRowWrappersForSublist(
+ context,
+ subList,
+ expandedSize,
+ rowListConstraints,
+ selectedText,
+ rowFlags,
+ listFlags,
+ isHalfList);
+ wrapperListBuilder.addAll(subWrappers);
+ expandedSize += subListSize;
+ } else {
+ RowWrapper.Builder wrapperBuilder = RowWrapper.wrap(rowObj, startIndex + expandedSize);
+ RowConstraints rowConstraints = rowListConstraints.getRowConstraints();
+ if (rowObj instanceof Row) {
+ Row row = (Row) rowObj;
+ labelIndex = addMetadataToRowWrapper(row, wrapperBuilder, labelIndex);
+
+ Toggle toggle = row.getToggle();
+ if (toggle != null) {
+ wrapperBuilder.setIsToggleChecked(toggle.isChecked());
+ }
+
+ wrapperBuilder.setSelectedText(selectedText);
+ }
+
+ wrapperListBuilder.add(
+ wrapperBuilder
+ .setRowFlags(rowFlags)
+ .setListFlags(listFlags)
+ .setIsHalfList(isHalfList)
+ .setSelectionGroup(selectionGroup)
+ .setRowConstraints(rowConstraints)
+ .build());
+
+ expandedSize++;
+ }
+ }
+
+ return wrapperListBuilder.build();
+ }
+
+ /**
+ * Adds any metadata from the original {@link Row} to its {@link RowWrapper}.
+ *
+ * <p>If a {@link Row} contains a default marker, this updates the marker to render a string based
+ * on the given {@code labelIndex}.
+ *
+ * @return the updated label index value that should be used for the next default marker
+ */
+ private static int addMetadataToRowWrapper(
+ Row row, RowWrapper.Builder wrapperBuilder, int labelIndex) {
+ Metadata metadata = row.getMetadata();
+ if (metadata != null) {
+ Place place = metadata.getPlace();
+ if (place != null) {
+ CarLocation location = place.getLocation();
+ if (location != null) {
+ // Assign any default markers (without text/icon) to show an integer value.
+ PlaceMarker marker = place.getMarker();
+ if (isDefaultMarker(marker)) {
+ PlaceMarker.Builder markerBuilder =
+ new PlaceMarker.Builder().setLabel(Integer.toString(labelIndex));
+ if (marker != null) {
+ CarColor markerColor = marker.getColor();
+ if (markerColor != null) {
+ markerBuilder.setColor(markerColor);
+ }
+ place = new Place.Builder(location).setMarker(markerBuilder.build()).build();
+ metadata = new Metadata.Builder(metadata).setPlace(place).build();
+ }
+ labelIndex++;
+ }
+ }
+ }
+
+ // Sets the metadata in the wrapper with the updated marker if set.
+ wrapperBuilder.setMetadata(metadata);
+ }
+
+ return labelIndex;
+ }
+
+ private static boolean isDefaultMarker(@Nullable PlaceMarker marker) {
+ return marker != null && marker.getIcon() == null && marker.getLabel() == null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ImmutableList<RowWrapper> createRowWrappersForSublist(
+ Context context,
+ ItemList subList,
+ int currentIndex,
+ RowListConstraints rowListConstraints,
+ @Nullable CarText selectedText,
+ @RowFlags int rowFlags,
+ @ListFlags int listFlags,
+ boolean isHalfList) {
+ // Create a selection group for this sub-list.
+ // Offset its indices it by the expanded size to account for any previously expanded
+ // sub-lists.
+ OnSelectedDelegate onSelectedDelegate = subList.getOnSelectedDelegate();
+ SelectionGroup subSelectionGroup =
+ onSelectedDelegate != null
+ ? SelectionGroup.create(
+ currentIndex,
+ currentIndex + subList.getItems().size() - 1,
+ currentIndex + subList.getSelectedIndex(),
+ onSelectedDelegate)
+ : null;
+
+ return buildRowWrappers(
+ context,
+ (List) subList.getItems(),
+ subSelectionGroup,
+ rowListConstraints,
+ selectedText,
+ rowFlags,
+ listFlags == 0 ? getDefaultListFlags(subList) : listFlags,
+ /* startIndex = */ currentIndex,
+ isHalfList);
+ }
+
+ private RowListWrapper(Builder builder) {
+ mIsLoading = builder.mIsLoading;
+ mRowList = builder.mRowList;
+ mImage = builder.mImage;
+ mEmptyListText = builder.mEmptyListText;
+ mOnRepeatedSelectionCallback = builder.mOnRepeatedSelectionCallback;
+ mOnItemVisibilityChangedDelegate = builder.mOnItemVisibilityChangedDelegate;
+ mRowListConstraints = builder.mRowListConstraints;
+ mListFlags = builder.mListFlags;
+ mIsRefresh = builder.mIsRefresh;
+ mIsHalfList = builder.mIsHalfList;
+ mRowWrappers =
+ buildRowWrappers(
+ builder.mContext,
+ builder.mRowList,
+ builder.mSelectionGroup,
+ builder.mRowListConstraints,
+ builder.mSelectedText,
+ builder.mRowFlags,
+ builder.mListFlags,
+ /* startIndex = */ 0,
+ builder.mIsHalfList);
+ }
+
+ /** The builder class for {@link RowListWrapper}. */
+ public static class Builder {
+ private final Context mContext;
+ @Nullable SelectionGroup mSelectionGroup;
+ @Nullable private List<Object> mRowList;
+ @RowFlags private int mRowFlags = ROW_FLAG_NONE;
+ @ListFlags private int mListFlags;
+ private RowListConstraints mRowListConstraints =
+ RowListConstraints.ROW_LIST_CONSTRAINTS_CONSERVATIVE;
+ @Nullable private Runnable mOnRepeatedSelectionCallback;
+ @Nullable private OnItemVisibilityChangedDelegate mOnItemVisibilityChangedDelegate;
+ private boolean mIsLoading;
+ private boolean mIsRefresh;
+ @Nullable private CarText mEmptyListText;
+ @Nullable private CarText mSelectedText;
+ @Nullable private CarIcon mImage;
+ private boolean mIsHalfList;
+
+ private Builder(Context context) {
+ mContext = context;
+ mRowList = null;
+ }
+
+ private Builder(Context context, RowListWrapper rowListWrapper) {
+ mContext = context;
+ mRowList = rowListWrapper.mRowList;
+ mImage = rowListWrapper.mImage;
+ mListFlags = rowListWrapper.mListFlags;
+ mRowListConstraints = rowListWrapper.mRowListConstraints;
+ mIsLoading = rowListWrapper.mIsLoading;
+ mIsRefresh = rowListWrapper.mIsRefresh;
+ mIsHalfList = rowListWrapper.mIsHalfList;
+ mEmptyListText = rowListWrapper.mEmptyListText;
+ mOnItemVisibilityChangedDelegate = rowListWrapper.mOnItemVisibilityChangedDelegate;
+ mOnRepeatedSelectionCallback = rowListWrapper.mOnRepeatedSelectionCallback;
+ }
+
+ /** Sets the set of rows that make up the list. */
+ public Builder setRows(@Nullable List<Object> rowList) {
+ mRowList = rowList;
+ return this;
+ }
+
+ /** Sets the image to be shown alongside the rows. */
+ public Builder setImage(@Nullable CarIcon image) {
+ mImage = image;
+ return this;
+ }
+
+ /** Set an extra callback for when a row in the list has been repeatedly selected. */
+ public Builder setOnRepeatedSelectionCallback(@Nullable Runnable runnable) {
+ mOnRepeatedSelectionCallback = runnable;
+ return this;
+ }
+
+ /**
+ * Sets the delegate to use to notify when when the visibility of items in the list change, for
+ * example, during scroll.
+ */
+ public Builder setOnItemVisibilityChangedDelegate(
+ @Nullable OnItemVisibilityChangedDelegate delegate) {
+ mOnItemVisibilityChangedDelegate = delegate;
+ return this;
+ }
+
+ /**
+ * Sets whether the list is loading.
+ *
+ * <p>If set to {@code true}, the UI shows a loading indicator and ignore any rows added to the
+ * list. If set to {@code false}, the UI shows the actual row contents.
+ */
+ public Builder setIsLoading(boolean isLoading) {
+ mIsLoading = isLoading;
+ return this;
+ }
+
+ /**
+ * Sets whether the list is a refresh of the existing list.
+ *
+ * <p>If set to {@code true}, the UI will not scroll to top, otherwise it will.
+ */
+ public Builder setIsRefresh(boolean isRefresh) {
+ mIsRefresh = isRefresh;
+ return this;
+ }
+
+ /** Sets the text to add to a row when it is selected. */
+ public Builder setRowSelectedText(@Nullable CarText selectedText) {
+ mSelectedText = selectedText;
+ return this;
+ }
+
+ /** Sets the text to display when the list is empty or {@code null} to not display any text. */
+ public Builder setEmptyListText(@Nullable CarText emptyListText) {
+ mEmptyListText = emptyListText;
+ return this;
+ }
+
+ /**
+ * Sets a selection group for this list.
+ *
+ * <p>Selection groups are used for defining a mutually-exclusive selectable range of rows,
+ * which can be used for example for radio buttons.
+ *
+ * @see SelectionGroup
+ */
+ public Builder setSelectionGroup(@Nullable SelectionGroup selectionGroup) {
+ mSelectionGroup = selectionGroup;
+ return this;
+ }
+
+ /**
+ * Set whether the list is a "half" list.
+ *
+ * <p>"Half list " is the term we use for lists that don't span the entire width of the screen
+ * (e.g. inside of a card in a map template). Note these don't necessarily take exactly half the
+ * width (depending on the screen width and how the card may adapt to it).
+ */
+ public Builder setIsHalfList(boolean isHalfList) {
+ mIsHalfList = isHalfList;
+ return this;
+ }
+
+ /** Sets the flags that control how to render individual rows. */
+ public Builder setRowFlags(@RowFlags int rowFlags) {
+ mRowFlags = rowFlags;
+ return this;
+ }
+
+ /** Sets the flags that control how to render the list. */
+ public Builder setListFlags(@ListFlags int listFlags) {
+ mListFlags = listFlags;
+ return this;
+ }
+
+ /** Sets the {@link RowListConstraints} that define the restrictions to apply to the list. */
+ public Builder setRowListConstraints(RowListConstraints constraints) {
+ mRowListConstraints = constraints;
+ return this;
+ }
+
+ /** Constructs a {@link RowListWrapper} instance from this builder. */
+ public RowListWrapper build() {
+ return new RowListWrapper(this);
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapperTemplate.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapperTemplate.java
new file mode 100644
index 0000000..d2793e3
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowListWrapperTemplate.java
@@ -0,0 +1,183 @@
+/*
+ * 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.template.view.model;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.model.Action;
+import androidx.car.app.model.ActionStrip;
+import androidx.car.app.model.CarText;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.model.ListTemplate;
+import androidx.car.app.model.Pane;
+import androidx.car.app.model.PaneTemplate;
+import androidx.car.app.model.Template;
+import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints;
+import com.android.car.libraries.apphost.distraction.constraints.RowListConstraints;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+
+/**
+ * A template that wraps {@link RowListWrapper}-based templates.
+ *
+ * <p>This template is used to to render full-screen homogeneous lists, or panes (which are also
+ * built with lists).
+ *
+ * @see #wrap
+ */
+public class RowListWrapperTemplate implements Template {
+ private final RowListWrapper mList;
+ @Nullable private final CarText mTitle;
+ @Nullable private final Action mHeaderAction;
+ @Nullable private final ActionStrip mActionStrip;
+ @Nullable private final List<Action> mActionList;
+ private final ActionsConstraints mActionsconstraints;
+
+ /** The original template being wrapped. */
+ private final Template mTemplate;
+
+ /** Returns the list used by the template. */
+ public RowListWrapper getList() {
+ return mList;
+ }
+
+ /** Returns the title of the template. */
+ @Nullable
+ public CarText getTitle() {
+ return mTitle;
+ }
+
+ /**
+ * Returns the {@link Action} to display in the template's header or {@code null} if one is not to
+ * be displayed.
+ */
+ @Nullable
+ public Action getHeaderAction() {
+ return mHeaderAction;
+ }
+
+ /**
+ * Returns the {@link ActionStrip} to display in the template or {@code null} if one is not to be
+ * displayed.
+ */
+ @Nullable
+ public ActionStrip getActionStrip() {
+ return mActionStrip;
+ }
+
+ /**
+ * Returns the list of {@link Action}s to display in the template or {@code null} if one is not to
+ * be displayed.
+ */
+ @Nullable
+ public List<Action> getActionList() {
+ return mActionList;
+ }
+
+ /**
+ * Returns the constraints for the actions in the template.
+ *
+ * @see ActionsConstraints
+ */
+ public ActionsConstraints getActionsConstraints() {
+ return mActionsconstraints;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "RowListWrapperTemplate(" + mTemplate + ")";
+ }
+
+ /**
+ * Returns a {@link RowListWrapperTemplate} instance that wraps the given {@code template}.
+ *
+ * @throws IllegalArgumentException if the {@code template} is not of a type that can be wrapped
+ */
+ public static RowListWrapperTemplate wrap(Context context, Template template, boolean isRefresh) {
+ if (template instanceof PaneTemplate) {
+ PaneTemplate paneTemplate = (PaneTemplate) template;
+ Pane pane = paneTemplate.getPane();
+ return new RowListWrapperTemplate(
+ template,
+ RowListWrapper.wrap(context, pane)
+ .setRowListConstraints(RowListConstraints.ROW_LIST_CONSTRAINTS_PANE)
+ .setIsRefresh(isRefresh)
+ .build(),
+ paneTemplate.getTitle(),
+ paneTemplate.getHeaderAction(),
+ paneTemplate.getActionStrip(),
+ paneTemplate.getPane().getActions(),
+ ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE);
+ } else if (template instanceof ListTemplate) {
+ ListTemplate listTemplate = (ListTemplate) template;
+ RowListWrapper.Builder listWrapperBuilder;
+ if (listTemplate.isLoading()) {
+ listWrapperBuilder =
+ RowListWrapper.wrap(context, ImmutableList.of())
+ .setIsLoading(true)
+ .setIsRefresh(isRefresh);
+ } else {
+ ItemList singleList = listTemplate.getSingleList();
+ listWrapperBuilder =
+ singleList == null
+ ? RowListWrapper.wrap(context, listTemplate.getSectionedLists())
+ .setIsRefresh(isRefresh)
+ : RowListWrapper.wrap(context, singleList).setIsRefresh(isRefresh);
+ }
+
+ return new RowListWrapperTemplate(
+ template,
+ listWrapperBuilder
+ .setRowListConstraints(RowListConstraints.ROW_LIST_CONSTRAINTS_FULL_LIST)
+ .setRowFlags(RowWrapper.DEFAULT_UNIFORM_LIST_ROW_FLAGS)
+ .build(),
+ listTemplate.getTitle(),
+ listTemplate.getHeaderAction(),
+ listTemplate.getActionStrip(),
+ /* actionList= */ null,
+ ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE);
+ } else {
+ throw new IllegalArgumentException(
+ "Unknown template class: " + template.getClass().getName());
+ }
+ }
+
+ /** Returns the template wrapped by this instance of a {@link RowListWrapperTemplate}. */
+ @VisibleForTesting
+ public Template getTemplate() {
+ return mTemplate;
+ }
+
+ private RowListWrapperTemplate(
+ Template template,
+ RowListWrapper list,
+ @Nullable CarText title,
+ @Nullable Action headerAction,
+ @Nullable ActionStrip actionStrip,
+ @Nullable List<Action> actionList,
+ ActionsConstraints actionsConstraints) {
+ mTemplate = template;
+ mList = list;
+ mTitle = title;
+ mHeaderAction = headerAction;
+ mActionStrip = actionStrip;
+ mActionList = actionList;
+ mActionsconstraints = actionsConstraints;
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowWrapper.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowWrapper.java
new file mode 100644
index 0000000..decaf66
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/RowWrapper.java
@@ -0,0 +1,271 @@
+/*
+ * 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.template.view.model;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.car.app.model.CarText;
+import androidx.car.app.model.Metadata;
+import androidx.car.app.model.Place;
+import androidx.car.app.model.Row;
+import com.android.car.libraries.apphost.distraction.constraints.RowConstraints;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** A host side wrapper for {@link Row} which can include extra metadata such as a {@link Place}. */
+public class RowWrapper {
+ /** Represents flags that control some attributes of the row. */
+ // TODO(b/174601019): clean this up along with ListFlags
+ @IntDef(
+ value = {ROW_FLAG_NONE, ROW_FLAG_SHOW_DIVIDERS, ROW_FLAG_SECTION_HEADER},
+ flag = true)
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface RowFlags {}
+
+ /** No flags applied to the row. */
+ public static final int ROW_FLAG_NONE = (1 << 0);
+
+ /** Whether to show dividers around the row. */
+ public static final int ROW_FLAG_SHOW_DIVIDERS = (1 << 1);
+
+ /**
+ * Whether the row is a section header.
+ *
+ * <p>Sections are used to group rows in the UI, for example, by showing them all within a block
+ * of the same background color.
+ *
+ * <p>A section header is a string of text above the section with a title for it.
+ */
+ public static final int ROW_FLAG_SECTION_HEADER = (1 << 2);
+
+ /** The default flags to use for uniform lists. */
+ public static final int DEFAULT_UNIFORM_LIST_ROW_FLAGS = ROW_FLAG_SHOW_DIVIDERS;
+
+ private final Object mRow;
+ private final int mRowIndex;
+ private final Metadata mMetadata;
+ @Nullable private final CarText mSelectedText;
+ @RowFlags private final int mRowFlags;
+ @RowListWrapper.ListFlags private final int mListFlags;
+ private final RowConstraints mRowConstraints;
+ private boolean mIsHalfList;
+
+ /**
+ * The selection group this row belongs to, or {@code null} if the row does not belong to one.
+ *
+ * <p>Selection groups are used to establish mutually-exclusive scopes of row selection, for
+ * example, to implement radio button groups.
+ *
+ * <p>The selection index in the group is mutable and allows for automatically updating it the UI
+ * at the host without a round-trip to the client to change the selection there.
+ */
+ @Nullable private final SelectionGroup mSelectionGroup;
+
+ /**
+ * Whether the toggle is checked.
+ *
+ * <p>This field is mutable so that we can remember toggle changes on the host without having to
+ * round-trip to the client when toggle states change.
+ *
+ * <p>It is initialized with the initial value from the model coming from the client, then can
+ * mutate after.
+ */
+ private boolean mIsToggleChecked;
+
+ /** Returns a {@link Builder} that wraps a row with the provided index. */
+ public static Builder wrap(Object row, int rowIndex) {
+ return new Builder(row, rowIndex);
+ }
+
+ @Override
+ public String toString() {
+ return "[" + mRow + ", group: " + mSelectionGroup + "]";
+ }
+
+ /** Returns the actual {@link Row} object that this instance is wrapping. */
+ public Object getRow() {
+ return mRow;
+ }
+
+ /** Returns the absolute index of the row in the flattened container list. */
+ public int getRowIndex() {
+ return mRowIndex;
+ }
+
+ /** Returns the {@link Metadata} that is associated with the row. */
+ public Metadata getMetadata() {
+ return mMetadata;
+ }
+
+ /** Returns the {@link CarText} that should be displayed in the row when it has focus. */
+ @Nullable
+ public CarText getSelectedText() {
+ return mSelectedText;
+ }
+
+ /**
+ * Returns the flags that control how to render this row.
+ *
+ * @see Builder#setRowFlags(int)
+ */
+ @RowFlags
+ public int getRowFlags() {
+ return mRowFlags;
+ }
+
+ /**
+ * Returns the flags that control how to render the list this row belongs to.
+ *
+ * @see Builder#setListFlags
+ */
+ @RowListWrapper.ListFlags
+ public int getListFlags() {
+ return mListFlags;
+ }
+
+ /**
+ * Returns whether the row belongs to a "half" list.
+ *
+ * @see Builder#setIsHalfList(boolean)
+ */
+ public boolean isHalfList() {
+ return mIsHalfList;
+ }
+
+ /**
+ * Returns the selection group this row belongs to.
+ *
+ * @see Builder#setSelectionGroup(SelectionGroup)
+ */
+ @Nullable
+ public SelectionGroup getSelectionGroup() {
+ return mSelectionGroup;
+ }
+
+ /**
+ * Returns whether the toggle in the row, if there is one, is checked.
+ *
+ * @see Builder#setIsToggleChecked(boolean)
+ */
+ public boolean isToggleChecked() {
+ return mIsToggleChecked;
+ }
+
+ /** Checks the toggle in the row if unchecked, and vice-versa. */
+ public void switchToggleState() {
+ mIsToggleChecked = !mIsToggleChecked;
+ }
+
+ /**
+ * Returns the {@link RowConstraints} that define the restrictions to apply to the row.
+ *
+ * @see Builder#setRowConstraints(RowConstraints)
+ */
+ public RowConstraints getRowConstraints() {
+ return mRowConstraints;
+ }
+
+ private RowWrapper(Builder builder) {
+ mRow = builder.mRow;
+ mRowIndex = builder.mRowIndex;
+ mMetadata = builder.mEmptyMetadata;
+ mSelectedText = builder.mSelectedText;
+ mRowFlags = builder.mRowFlags;
+ mListFlags = builder.mListFlags;
+ mSelectionGroup = builder.mSelectionGroup;
+ mIsToggleChecked = builder.mIsToggleChecked;
+ mRowConstraints = builder.mRowConstraints;
+ mIsHalfList = builder.mIsHalfList;
+ }
+
+ /** The builder class for {@link RowWrapper}. */
+ public static class Builder {
+ private final Object mRow;
+ private final int mRowIndex;
+ private Metadata mEmptyMetadata = Metadata.EMPTY_METADATA;
+ @RowListWrapper.ListFlags private int mListFlags;
+ @RowFlags private int mRowFlags = ROW_FLAG_NONE;
+ @Nullable private SelectionGroup mSelectionGroup;
+ private boolean mIsToggleChecked;
+ @Nullable private CarText mSelectedText;
+ private RowConstraints mRowConstraints = RowConstraints.ROW_CONSTRAINTS_CONSERVATIVE;
+ private boolean mIsHalfList;
+
+ /** Sets the {@link Metadata} associated with this row. */
+ public Builder setMetadata(Metadata metadata) {
+ mEmptyMetadata = metadata;
+ return this;
+ }
+
+ /** Sets the text to display in the row when it is selected. */
+ public Builder setSelectedText(@Nullable CarText selectedText) {
+ mSelectedText = selectedText;
+ return this;
+ }
+
+ /** Sets the flags that control how to render this row. */
+ public Builder setRowFlags(@RowFlags int rowFlags) {
+ mRowFlags = rowFlags;
+ return this;
+ }
+
+ /** Sets the flags that control how to render the list this row belongs to. */
+ public Builder setListFlags(@RowListWrapper.ListFlags int listFlags) {
+ mListFlags = listFlags;
+ return this;
+ }
+
+ /**
+ * Set whether the list this row belongs to is a "half" list.
+ *
+ * <p>"Half list " is the term we use for lists that don't span the entire width of the screen
+ * (e.g. inside of a card in a map template). Note these don't necessarily take exactly half the
+ * width (depending on the screen width and how the card may adapt to it).
+ */
+ public Builder setIsHalfList(boolean isHalfList) {
+ mIsHalfList = isHalfList;
+ return this;
+ }
+
+ /** Sets the selection group this row belongs to. */
+ public Builder setSelectionGroup(@Nullable SelectionGroup selectionGroup) {
+ mSelectionGroup = selectionGroup;
+ return this;
+ }
+
+ /** Sets whether the toggle in the row, if there is one, should be displayed checked. */
+ public Builder setIsToggleChecked(boolean isToggleChecked) {
+ mIsToggleChecked = isToggleChecked;
+ return this;
+ }
+
+ /** Sets the {@link RowConstraints} that define the restrictions to apply to the row. */
+ public Builder setRowConstraints(RowConstraints rowConstraints) {
+ mRowConstraints = rowConstraints;
+ return this;
+ }
+
+ /** Constructs a {@link RowWrapper} instance from this builder. */
+ public RowWrapper build() {
+ return new RowWrapper(this);
+ }
+
+ private Builder(Object row, int rowIndex) {
+ mRow = row;
+ mRowIndex = rowIndex;
+ }
+ }
+}
diff --git a/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/SelectionGroup.java b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/SelectionGroup.java
new file mode 100644
index 0000000..39f8195
--- /dev/null
+++ b/Host/app/apphost/src/main/java/com/android/car/libraries/apphost/template/view/model/SelectionGroup.java
@@ -0,0 +1,104 @@
+/*
+ * 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.template.view.model;
+
+import androidx.car.app.model.OnSelectedDelegate;
+
+/**
+ * Represents a set of rows inside of a list that describe a mutually-exclusive selection group.
+ *
+ * <p>This can be used to describe multiple radio sub-lists within the same list.
+ */
+public class SelectionGroup {
+ private final int mStartIndex;
+ private final int mEndIndex;
+ private final OnSelectedDelegate mOnSelectedDelegate;
+
+ /**
+ * The currently selected index.
+ *
+ * <p>The selection index in the group is mutable and allows for automatically updating it the UI
+ * at the host without a round-trip to the client to change the selection there.
+ */
+ private int mSelectedIndex;
+
+ /**
+ * Returns an instance of a {@link SelectionGroup}.
+ *
+ * @param startIndex the index where the selection group starts, inclusive
+ * @param endIndex the index where the selection ends, inclusive
+ * @param selectedIndex the index of the item in the selection group to select
+ * @param onSelectedDelegate a delegate to invoke upon selection change events
+ */
+ public static SelectionGroup create(
+ int startIndex, int endIndex, int selectedIndex, OnSelectedDelegate onSelectedDelegate) {
+ return new SelectionGroup(startIndex, endIndex, selectedIndex, onSelectedDelegate);
+ }
+
+ /** Returns whether the item at the given index is selected. */
+ public boolean isSelected(int index) {
+ return index == mSelectedIndex;
+ }
+
+ /** Returns the index of the item that's currently selected in the group. */
+ public int getSelectedIndex() {
+ return mSelectedIndex;
+ }
+
+ /** Returns the index relative to the selection group. */
+ public int getRelativeIndex(int index) {
+ return index - mStartIndex;
+ }
+
+ /** Returns the delegate to invoke upon selection change events. */
+ public OnSelectedDelegate getOnSelectedDelegate() {
+ return mOnSelectedDelegate;
+ }
+
+ /** Sets the index of the item to select in the group. */
+ public void setSelectedIndex(int selectedIndex) {
+ checkSelectedIndexOutOfBounds(mStartIndex, mEndIndex, selectedIndex);
+ mSelectedIndex = selectedIndex;
+ }
+
+ @Override
+ public String toString() {
+ return "[start: " + mStartIndex + ", end: " + mEndIndex + ", selected: " + mSelectedIndex + "]";
+ }
+
+ private SelectionGroup(
+ int startIndex, int endIndex, int selectedIndex, OnSelectedDelegate onSelectedDelegate) {
+ checkSelectedIndexOutOfBounds(startIndex, endIndex, selectedIndex);
+ mStartIndex = startIndex;
+ mEndIndex = endIndex;
+ mSelectedIndex = selectedIndex;
+ mOnSelectedDelegate = onSelectedDelegate;
+ }
+
+ private static void checkSelectedIndexOutOfBounds(
+ int startIndex, int endIndex, int selectedIndex) {
+ if (selectedIndex < startIndex || selectedIndex > endIndex) {
+ throw new IndexOutOfBoundsException(
+ "Selected index "
+ + selectedIndex
+ + " not within bounds of ["
+ + startIndex
+ + ", "
+ + endIndex
+ + "]");
+ }
+ }
+}
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);
+}