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